From 93ca33c36a0ae5dd5f54ed708233832efeb4544d Mon Sep 17 00:00:00 2001 From: Pierre-Marie Charavel Date: Tue, 24 Feb 2026 06:55:05 -0500 Subject: [PATCH] Add UnrealDocGenerator tool and UE API skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ue_parser.py: position-based UE C++ header parser - ue_markdown.py: compact agent-optimised Markdown renderer - generate.py: two-pass CLI (parse-all → type index → render-all) - samples/: representative UE headers (GeomUtils, AIController, GameplayTagsManager) - .claude/skills/ue-api/: Claude Code skill for querying UE docs + source headers - CLAUDE.md: architecture notes, usage, critical gotchas Co-Authored-By: Claude Sonnet 4.6 --- .../plugins/ue-api/.claude-plugin/plugin.json | 5 + .claude/plugins/ue-api/skills/ue-api/SKILL.md | 109 ++ .claude/skills/ue-api/SKILL.md | 109 ++ .gitignore | 740 +++++++++++ CLAUDE.md | 49 + README.md | 2 + generate.py | 135 ++ samples/AIController.h | 465 +++++++ samples/GameplayTagsManager.h | 1010 ++++++++++++++ samples/GeomUtils.h | 175 +++ ue_markdown.py | 405 ++++++ ue_parser.py | 1183 +++++++++++++++++ 12 files changed, 4387 insertions(+) create mode 100644 .claude/plugins/ue-api/.claude-plugin/plugin.json create mode 100644 .claude/plugins/ue-api/skills/ue-api/SKILL.md create mode 100644 .claude/skills/ue-api/SKILL.md create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 generate.py create mode 100644 samples/AIController.h create mode 100644 samples/GameplayTagsManager.h create mode 100644 samples/GeomUtils.h create mode 100644 ue_markdown.py create mode 100644 ue_parser.py diff --git a/.claude/plugins/ue-api/.claude-plugin/plugin.json b/.claude/plugins/ue-api/.claude-plugin/plugin.json new file mode 100644 index 0000000..ecbe261 --- /dev/null +++ b/.claude/plugins/ue-api/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "ue-api", + "description": "Unreal Engine API documentation skill — queries generated docs and source headers to answer questions about UE C++ APIs, class hierarchies, and system flows.", + "version": "1.0.0" +} diff --git a/.claude/plugins/ue-api/skills/ue-api/SKILL.md b/.claude/plugins/ue-api/skills/ue-api/SKILL.md new file mode 100644 index 0000000..fcf21b1 --- /dev/null +++ b/.claude/plugins/ue-api/skills/ue-api/SKILL.md @@ -0,0 +1,109 @@ +--- +name: ue-api +description: > + Use this skill when the user asks anything about Unreal Engine C++ APIs, + class hierarchies, system flows, or how engine subsystems work together. + Trigger phrases include: "how does X work in UE", "what is the flow for", + "which class handles", "what virtual functions", "UE API for", "Unreal Engine + architecture", and any question that names UE types like ACharacter, + UActorComponent, APlayerController, UBehaviorTreeComponent, APawn, etc. + Always use this skill for Unreal Engine questions — don't rely on training + data alone when local documentation is available. +--- + +# UE API Skill + +Answer questions about Unreal Engine C++ APIs and system flows using the local +documentation corpus as the fast primary source, then source headers for depth. + +## Configuration + +Two environment variables control where to look: + +| Variable | Purpose | Example | +|---|---|---| +| `UE_DOCS_PATH` | Root of the generated documentation | `/home/user/ue-docs` | +| `UE_ENGINE_ROOT` | UE engine source root (for header fallback) | `/home/user/UnrealEngine` | + +**Resolve them at the start of every query:** + +```bash +echo "$UE_DOCS_PATH" +echo "$UE_ENGINE_ROOT" +``` + +If `UE_DOCS_PATH` is unset, ask the user where their generated docs are before +proceeding. If `UE_ENGINE_ROOT` is unset, only ask when a question actually +requires source headers — don't interrupt doc-only queries. + +The type index is always at `$UE_DOCS_PATH/type-index.txt`. + +## Step 1 — Identify types, resolve paths + +Extract UE type names from the question (e.g. `APlayerController`, `APawn`, +`UBehaviorTreeComponent`). Resolve all of them in a single grep: + +```bash +grep "^APlayerController:\|^APawn:\|^ACharacter:" "$UE_DOCS_PATH/type-index.txt" +``` + +Paths in the index are relative to `$UE_DOCS_PATH` — prepend it when reading: + +```bash +# index returns: AController: Engine/Classes/GameFramework/Controller.md +# read as: +Read "$UE_DOCS_PATH/Engine/Classes/GameFramework/Controller.md" +``` + +The `.md` files are compact by design — only items with C++ doc comments, +no deprecated entries, enums collapsed when undescribed. + +## Step 2 — Follow the trail + +Inline links in `*Inherits*:` lines and function signatures point to related +types. Follow them when the question spans multiple classes. A second grep on +`type-index.txt` is always cheaper than guessing paths. + +## Step 3 — Escalate to source headers when docs aren't enough + +The docs only surface items with C++ doc comments. Go to `.h` files when: + +- The exact call order or implementation logic isn't described in any comment +- A function or member is absent from `.md` files (no doc comment) +- The question involves macros: `UCLASS`, `UPROPERTY`, `UFUNCTION`, + `DECLARE_DELEGATE_*`, `GENERATED_BODY`, etc. +- Private or protected members are relevant to the answer +- The user asks about edge-case behaviour ("what happens when X is null?") + +Search under `$UE_ENGINE_ROOT/Engine/Source/` — e.g.: + +```bash +Glob("**/*Controller*.h", path="$UE_ENGINE_ROOT/Engine/Source/Runtime/Engine") +Grep("void Possess", path="$UE_ENGINE_ROOT/Engine/Source") +``` + +## Output format + +Lead with the direct answer or a concise flow description. For multi-step +flows use an ASCII sequence or numbered list. For single-class API questions, +a brief prose answer with the key function signatures is enough. + +Cite every substantive claim — `(Controller.md)` for docs, `(Controller.h:142)` +for source. Mark source-derived facts as *implementation detail* since they can +change across engine versions; doc-derived facts reflect the stable API contract. + +## Examples + +**"How does APlayerController possess APawn?"** +→ check `$UE_DOCS_PATH` → grep type-index for AController, APawn, +APlayerController, ACharacter → read the four `.md` files → ASCII diagram + +**"What virtual functions does ACharacter expose for movement?"** +→ grep for ACharacter, UCharacterMovementComponent → read docs → list virtuals + +**"What does UFUNCTION(BlueprintCallable) expand to?"** +→ docs won't help (macro) → search `$UE_ENGINE_ROOT` for `UFUNCTION` definition + +**"How does the behavior tree pick which task to run?"** +→ grep for UBehaviorTreeComponent, UBTCompositeNode, UBTDecorator → read docs +→ if execution order is still unclear, escalate to `BehaviorTreeComponent.h` diff --git a/.claude/skills/ue-api/SKILL.md b/.claude/skills/ue-api/SKILL.md new file mode 100644 index 0000000..fcf21b1 --- /dev/null +++ b/.claude/skills/ue-api/SKILL.md @@ -0,0 +1,109 @@ +--- +name: ue-api +description: > + Use this skill when the user asks anything about Unreal Engine C++ APIs, + class hierarchies, system flows, or how engine subsystems work together. + Trigger phrases include: "how does X work in UE", "what is the flow for", + "which class handles", "what virtual functions", "UE API for", "Unreal Engine + architecture", and any question that names UE types like ACharacter, + UActorComponent, APlayerController, UBehaviorTreeComponent, APawn, etc. + Always use this skill for Unreal Engine questions — don't rely on training + data alone when local documentation is available. +--- + +# UE API Skill + +Answer questions about Unreal Engine C++ APIs and system flows using the local +documentation corpus as the fast primary source, then source headers for depth. + +## Configuration + +Two environment variables control where to look: + +| Variable | Purpose | Example | +|---|---|---| +| `UE_DOCS_PATH` | Root of the generated documentation | `/home/user/ue-docs` | +| `UE_ENGINE_ROOT` | UE engine source root (for header fallback) | `/home/user/UnrealEngine` | + +**Resolve them at the start of every query:** + +```bash +echo "$UE_DOCS_PATH" +echo "$UE_ENGINE_ROOT" +``` + +If `UE_DOCS_PATH` is unset, ask the user where their generated docs are before +proceeding. If `UE_ENGINE_ROOT` is unset, only ask when a question actually +requires source headers — don't interrupt doc-only queries. + +The type index is always at `$UE_DOCS_PATH/type-index.txt`. + +## Step 1 — Identify types, resolve paths + +Extract UE type names from the question (e.g. `APlayerController`, `APawn`, +`UBehaviorTreeComponent`). Resolve all of them in a single grep: + +```bash +grep "^APlayerController:\|^APawn:\|^ACharacter:" "$UE_DOCS_PATH/type-index.txt" +``` + +Paths in the index are relative to `$UE_DOCS_PATH` — prepend it when reading: + +```bash +# index returns: AController: Engine/Classes/GameFramework/Controller.md +# read as: +Read "$UE_DOCS_PATH/Engine/Classes/GameFramework/Controller.md" +``` + +The `.md` files are compact by design — only items with C++ doc comments, +no deprecated entries, enums collapsed when undescribed. + +## Step 2 — Follow the trail + +Inline links in `*Inherits*:` lines and function signatures point to related +types. Follow them when the question spans multiple classes. A second grep on +`type-index.txt` is always cheaper than guessing paths. + +## Step 3 — Escalate to source headers when docs aren't enough + +The docs only surface items with C++ doc comments. Go to `.h` files when: + +- The exact call order or implementation logic isn't described in any comment +- A function or member is absent from `.md` files (no doc comment) +- The question involves macros: `UCLASS`, `UPROPERTY`, `UFUNCTION`, + `DECLARE_DELEGATE_*`, `GENERATED_BODY`, etc. +- Private or protected members are relevant to the answer +- The user asks about edge-case behaviour ("what happens when X is null?") + +Search under `$UE_ENGINE_ROOT/Engine/Source/` — e.g.: + +```bash +Glob("**/*Controller*.h", path="$UE_ENGINE_ROOT/Engine/Source/Runtime/Engine") +Grep("void Possess", path="$UE_ENGINE_ROOT/Engine/Source") +``` + +## Output format + +Lead with the direct answer or a concise flow description. For multi-step +flows use an ASCII sequence or numbered list. For single-class API questions, +a brief prose answer with the key function signatures is enough. + +Cite every substantive claim — `(Controller.md)` for docs, `(Controller.h:142)` +for source. Mark source-derived facts as *implementation detail* since they can +change across engine versions; doc-derived facts reflect the stable API contract. + +## Examples + +**"How does APlayerController possess APawn?"** +→ check `$UE_DOCS_PATH` → grep type-index for AController, APawn, +APlayerController, ACharacter → read the four `.md` files → ASCII diagram + +**"What virtual functions does ACharacter expose for movement?"** +→ grep for ACharacter, UCharacterMovementComponent → read docs → list virtuals + +**"What does UFUNCTION(BlueprintCallable) expand to?"** +→ docs won't help (macro) → search `$UE_ENGINE_ROOT` for `UFUNCTION` definition + +**"How does the behavior tree pick which task to run?"** +→ grep for UBehaviorTreeComponent, UBTCompositeNode, UBTDecorator → read docs +→ if execution order is still unclear, escalate to `BehaviorTreeComponent.h` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa916fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,740 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# ---> JetBrains +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +# ---> VisualStudio +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +# ---> VisualStudioCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# ---> macOS +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# ---> Linux +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +# ---> Windows +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..601a1b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Purpose + +UnrealDocGenerator is a tool (in development) for generating documentation from Unreal Engine C++ header files. The `samples/` directory contains representative Unreal Engine headers used as test inputs: + +- `samples/GeomUtils.h` — Small utility header (~6 KB): geometry/math helpers in `UE::AI` namespace +- `samples/AIController.h` — Medium header (~22 KB): `AAIController` class with movement, behavior tree, perception, and blackboard integration +- `samples/GameplayTagsManager.h` — Large/complex header (~43 KB): `UGameplayTagsManager` singleton with tag trees, INI/DataTable loading, and editor support + +These samples cover a range of complexity (free functions, large classes, deeply nested types, macros, delegates) suitable for stress-testing a parser or doc generator. + +## Current State + +Implementation complete. Three Python files: +- `ue_parser.py` — Parses UE headers into dataclasses +- `ue_markdown.py` — Renders parsed data as Markdown (ultra-compact format, documented items only) +- `generate.py` — CLI entry point; two-pass pipeline (parse-all → build type index → render-all) + +## Usage + +```bash +python generate.py samples/ docs/ # process directory → docs/ +python generate.py samples/AIController.h docs/ # single file +``` + +Output: one `.md` per `.h` + `docs/type-index.txt` (compact `TypeName: path/to/File.md` lookup). + +Agent lookup pattern: `grep "^AController:" docs/type-index.txt` → instant single-line result. + +## Architecture Notes + +- Parser uses a **position-based scanner**, not line-by-line regex, to handle nested braces correctly. +- `find_matching_close()` extracts balanced `{}`/`()` while skipping `//`, `/* */`, and string literals. +- Only items with actual C++ doc comments (`/** */`) are included in output (`_has_doc()` filter). +- Two-pass pipeline in `generate.py`: Pass 1 parses all files, Pass 2 renders with cross-reference links. +- Inline cross-references: base class names linked to their `.md` when in corpus (`_make_type_link()`). +- No "See also" section — inline links in `*Inherits*:` lines cover cross-file navigation. +- Deprecated functions excluded from output (`not f.is_deprecated` in visibility filter). +- Enums with no value descriptions use compact inline format (`Values: A, B, C`) instead of a table. +- Placeholder descriptions (`------//`) filtered by `_PLACEHOLDER_RE` in `_fn_body()`. + +## Critical Gotchas + +- **Never** use `\s` inside a `[...]` character class with `*?` on C++ source — causes catastrophic regex backtracking (hangs on `AIController.h`). Use line-based scanning instead. +- Identifier regex must be `r'\w+'`, **not** `r'[\w:~]+'` — the latter consumes `public:` as one token, breaking access specifier detection (all members become `private`). +- Module name casing: use `_caps_to_camel()` (not `.title()`) — `AIMODULE.title()` → `Aimodule` instead of `AIModule`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f55edf9 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# ue-ai-skill + diff --git a/generate.py b/generate.py new file mode 100644 index 0000000..9ff5b2e --- /dev/null +++ b/generate.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +""" +generate.py — CLI for UnrealDocGenerator. + +Usage: + python generate.py + + can be a single .h file or a directory (processed recursively). + +Two-pass pipeline: + Pass 1 — parse every header, build a corpus-wide type index + Pass 2 — render each header with cross-reference links injected +""" +import sys +import os +import re +from pathlib import Path + +from ue_parser import parse_header, ParsedHeader +from ue_markdown import render_header + + +# --------------------------------------------------------------------------- +# Type index +# --------------------------------------------------------------------------- + +def build_type_index(parsed_list: list[tuple[Path, ParsedHeader]], + input_base: Path) -> dict[str, str]: + """ + Returns {TypeName: md_path_relative_to_docs_root} for every + class, struct, enum, and delegate in the corpus. + """ + index: dict[str, str] = {} + for h, parsed in parsed_list: + try: + rel = h.relative_to(input_base) + except ValueError: + rel = Path(h.name) + md_rel = str(rel.with_suffix('.md')) + + for ci in parsed.classes: + index[ci.name] = md_rel + for ei in parsed.enums: + index[ei.name] = md_rel + for di in parsed.delegates: + index[di.name] = md_rel + # namespace names are not types — skip + return index + + +# --------------------------------------------------------------------------- +# Type index file +# --------------------------------------------------------------------------- + +def write_type_index(type_index: dict[str, str], output_dir: Path) -> None: + """ + Write docs/type-index.txt — compact TypeName: path/to/File.md lookup. + One entry per line, alphabetically sorted. Agents can grep this file + to resolve a type name to its documentation path. + """ + _valid_name = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') + lines = sorted(f"{name}: {path}" for name, path in type_index.items() + if _valid_name.match(name)) + out = output_dir / "type-index.txt" + out.write_text('\n'.join(lines) + '\n', encoding='utf-8') + print(f"Written {out}") + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + if len(sys.argv) < 3: + print("Usage: python generate.py ", file=sys.stderr) + sys.exit(1) + + input_arg = Path(sys.argv[1]) + output_dir = Path(sys.argv[2]) + output_dir.mkdir(parents=True, exist_ok=True) + + # Collect input files + if input_arg.is_file(): + headers = [input_arg] + input_base = input_arg.parent + elif input_arg.is_dir(): + headers = sorted(input_arg.rglob('*.h')) + input_base = input_arg + else: + print(f"Error: {input_arg} is not a file or directory", file=sys.stderr) + sys.exit(1) + + if not headers: + print("No .h files found.", file=sys.stderr) + sys.exit(1) + + # --- Pass 1: parse all --- + parsed_list: list[tuple[Path, ParsedHeader]] = [] + for h in headers: + print(f"Parsing {h} ...") + try: + parsed = parse_header(str(h)) + parsed_list.append((h, parsed)) + except Exception as exc: + print(f" ERROR parsing {h}: {exc}", file=sys.stderr) + + # --- Build corpus-wide type index --- + type_index = build_type_index(parsed_list, input_base) + + # --- Pass 2: render all --- + success = 0 + for h, parsed in parsed_list: + print(f"Rendering {h} ...") + try: + rel = h.relative_to(input_base) + except ValueError: + rel = Path(h.name) + + current_md = str(rel.with_suffix('.md')) + out_path = output_dir / current_md + out_path.parent.mkdir(parents=True, exist_ok=True) + + try: + md = render_header(parsed, type_index=type_index, current_md=current_md) + out_path.write_text(md, encoding='utf-8') + success += 1 + except Exception as exc: + print(f" ERROR rendering {h}: {exc}", file=sys.stderr) + + write_type_index(type_index, output_dir) + print(f"\nGenerated {success}/{len(parsed_list)} files + type-index.txt in {output_dir}/") + + +if __name__ == '__main__': + main() diff --git a/samples/AIController.h b/samples/AIController.h new file mode 100644 index 0000000..cb74dad --- /dev/null +++ b/samples/AIController.h @@ -0,0 +1,465 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/ObjectMacros.h" +#include "UObject/UObjectGlobals.h" +#include "Templates/SubclassOf.h" +#include "EngineDefines.h" +#if UE_ENABLE_INCLUDE_ORDER_DEPRECATED_IN_5_4 +#include "NavFilters/NavigationQueryFilter.h" +#endif +#include "AITypes.h" +#include "GameplayTaskOwnerInterface.h" +#include "GameplayTask.h" +#include "GameFramework/Pawn.h" +#include "GameFramework/Controller.h" +#include "Perception/AIPerceptionListenerInterface.h" +#include "GenericTeamAgentInterface.h" +#include "VisualLogger/VisualLoggerDebugSnapshotInterface.h" +#include "AIController.generated.h" + +class FDebugDisplayInfo; +class UAIPerceptionComponent; +class UBehaviorTree; +class UBlackboardComponent; +class UBlackboardData; +class UBrainComponent; +class UCanvas; +class UGameplayTaskResource; +class UGameplayTasksComponent; +class UPathFollowingComponent; + +namespace EPathFollowingRequestResult { enum Type : int; } +namespace EPathFollowingResult { enum Type : int; } +namespace EPathFollowingStatus { enum Type : int; } + +#if ENABLE_VISUAL_LOG +struct FVisualLogEntry; +#endif // ENABLE_VISUAL_LOG +struct FPathFindingQuery; +struct FPathFollowingRequestResult; +struct FPathFollowingResult; + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FAIMoveCompletedSignature, FAIRequestID, RequestID, EPathFollowingResult::Type, Result); + +// the reason for this being namespace instead of a regular enum is +// so that it can be expanded in game-specific code +// @todo this is a bit messy, needs to be refactored +namespace EAIFocusPriority +{ + typedef uint8 Type; + + inline const Type Default = 0; + inline const Type Move = 1; + inline const Type Gameplay = 2; + + inline const Type LastFocusPriority = Gameplay; +} + +struct FFocusKnowledge +{ + struct FFocusItem + { + TWeakObjectPtr Actor; + FVector Position; + + FFocusItem() + { + Actor = nullptr; + Position = FAISystem::InvalidLocation; + } + }; + + TArray Priorities; +}; + +//~============================================================================= +/** + * AIController is the base class of controllers for AI-controlled Pawns. + * + * Controllers are non-physical actors that can be attached to a pawn to control its actions. + * AIControllers manage the artificial intelligence for the pawns they control. + * In networked games, they only exist on the server. + * + * @see https://docs.unrealengine.com/latest/INT/Gameplay/Framework/Controller/ + */ + +UCLASS(ClassGroup = AI, BlueprintType, Blueprintable, MinimalAPI) +class AAIController : public AController, public IAIPerceptionListenerInterface, public IGameplayTaskOwnerInterface, public IGenericTeamAgentInterface, public IVisualLoggerDebugSnapshotInterface +{ + GENERATED_BODY() + + FGameplayResourceSet ScriptClaimedResources; +protected: + FFocusKnowledge FocusInformation; + + /** By default AI's logic does not start when controlled Pawn is possessed. Setting this flag to true + * will make AI logic start when pawn is possessed */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = AI) + uint32 bStartAILogicOnPossess : 1; + + /** By default AI's logic gets stopped when controlled Pawn is unpossessed. Setting this flag to false + * will make AI logic persist past losing control over a pawn */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = AI) + uint32 bStopAILogicOnUnposses : 1; + +public: + /** used for alternating LineOfSight traces */ + UPROPERTY() + mutable uint32 bLOSflag : 1; + + /** Skip extra line of sight traces to extremities of target being checked. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = AI) + uint32 bSkipExtraLOSChecks : 1; + + /** Is strafing allowed during movement? */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = AI) + uint32 bAllowStrafe : 1; + + /** Specifies if this AI wants its own PlayerState. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = AI) + uint32 bWantsPlayerState : 1; + + /** Copy Pawn rotation to ControlRotation, if there is no focus point. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = AI) + uint32 bSetControlRotationFromPawnOrientation:1; + +private: + + /** Component used for moving along a path. */ + UPROPERTY(VisibleDefaultsOnly, Category = AI) + TObjectPtr PathFollowingComponent; + +public: + + /** Component responsible for behaviors. */ + UPROPERTY(BlueprintReadWrite, Category = AI) + TObjectPtr BrainComponent; + + UPROPERTY(VisibleDefaultsOnly, Category = AI) + TObjectPtr PerceptionComponent; + +protected: + /** blackboard */ + UPROPERTY(BlueprintReadOnly, Category = AI, meta = (AllowPrivateAccess = "true")) + TObjectPtr Blackboard; + + UPROPERTY() + TObjectPtr CachedGameplayTasksComponent; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = AI) + TSubclassOf DefaultNavigationFilterClass; + +public: + + AIMODULE_API AAIController(const FObjectInitializer& ObjectInitializer = FObjectInitializer::Get()); + + AIMODULE_API virtual void SetPawn(APawn* InPawn) override; + + /** Makes AI go toward specified Goal actor (destination will be continuously updated), aborts any active path following + * @param AcceptanceRadius - finish move if pawn gets close enough + * @param bStopOnOverlap - add pawn's radius to AcceptanceRadius + * @param bUsePathfinding - use navigation data to calculate path (otherwise it will go in straight line) + * @param bCanStrafe - set focus related flag: bAllowStrafe + * @param FilterClass - navigation filter for pathfinding adjustments. If none specified DefaultNavigationFilterClass will be used + * @param bAllowPartialPath - use incomplete path when goal can't be reached + * @note AcceptanceRadius has default value or -1 due to Header Parser not being able to recognize UPathFollowingComponent::DefaultAcceptanceRadius + */ + UFUNCTION(BlueprintCallable, Category = "AI|Navigation", Meta = (AdvancedDisplay = "bStopOnOverlap,bCanStrafe,bAllowPartialPath")) + AIMODULE_API EPathFollowingRequestResult::Type MoveToActor(AActor* Goal, float AcceptanceRadius = -1, bool bStopOnOverlap = true, + bool bUsePathfinding = true, bool bCanStrafe = true, + TSubclassOf FilterClass = {}, bool bAllowPartialPath = true); + + /** Makes AI go toward specified Dest location, aborts any active path following + * @param AcceptanceRadius - finish move if pawn gets close enough + * @param bStopOnOverlap - add pawn's radius to AcceptanceRadius + * @param bUsePathfinding - use navigation data to calculate path (otherwise it will go in straight line) + * @param bProjectDestinationToNavigation - project location on navigation data before using it + * @param bCanStrafe - set focus related flag: bAllowStrafe + * @param FilterClass - navigation filter for pathfinding adjustments. If none specified DefaultNavigationFilterClass will be used + * @param bAllowPartialPath - use incomplete path when goal can't be reached + * @note AcceptanceRadius has default value or -1 due to Header Parser not being able to recognize UPathFollowingComponent::DefaultAcceptanceRadius + */ + UFUNCTION(BlueprintCallable, Category = "AI|Navigation", Meta = (AdvancedDisplay = "bStopOnOverlap,bCanStrafe,bAllowPartialPath")) + AIMODULE_API EPathFollowingRequestResult::Type MoveToLocation(const FVector& Dest, float AcceptanceRadius = -1, bool bStopOnOverlap = true, + bool bUsePathfinding = true, bool bProjectDestinationToNavigation = false, bool bCanStrafe = true, + TSubclassOf FilterClass = {}, bool bAllowPartialPath = true); + + /** Makes AI go toward specified destination + * @param MoveRequest - details about move + * @param OutPath - optional output param, filled in with assigned path + * @return struct holding MoveId and enum code + */ + AIMODULE_API virtual FPathFollowingRequestResult MoveTo(const FAIMoveRequest& MoveRequest, FNavPathSharedPtr* OutPath = nullptr); + + /** Passes move request and path object to path following */ + AIMODULE_API virtual FAIRequestID RequestMove(const FAIMoveRequest& MoveRequest, FNavPathSharedPtr Path); + + /** Finds path for given move request + * @param MoveRequest - details about move + * @param Query - pathfinding query for navigation system + * @param OutPath - generated path + */ + AIMODULE_API virtual void FindPathForMoveRequest(const FAIMoveRequest& MoveRequest, FPathFindingQuery& Query, FNavPathSharedPtr& OutPath) const; + + /** Helper function for creating pathfinding query for this agent from move request data */ + AIMODULE_API bool BuildPathfindingQuery(const FAIMoveRequest& MoveRequest, FPathFindingQuery& OutQuery) const; + + /** Helper function for creating pathfinding query for this agent from move request data and starting location */ + AIMODULE_API bool BuildPathfindingQuery(const FAIMoveRequest& MoveRequest, const FVector& StartLocation, FPathFindingQuery& OutQuery) const; + + UE_DEPRECATED_FORGAME(4.13, "This function is now deprecated, please use FindPathForMoveRequest() for adjusting Query or BuildPathfindingQuery() for getting one.") + AIMODULE_API virtual bool PreparePathfinding(const FAIMoveRequest& MoveRequest, FPathFindingQuery& Query); + + UE_DEPRECATED_FORGAME(4.13, "This function is now deprecated, please use FindPathForMoveRequest() for adjusting pathfinding or path postprocess.") + AIMODULE_API virtual FAIRequestID RequestPathAndMove(const FAIMoveRequest& MoveRequest, FPathFindingQuery& Query); + + /** if AI is currently moving due to request given by RequestToPause, then the move will be paused */ + AIMODULE_API bool PauseMove(FAIRequestID RequestToPause); + + /** resumes last AI-performed, paused request provided it's ID was equivalent to RequestToResume */ + AIMODULE_API bool ResumeMove(FAIRequestID RequestToResume); + + /** Aborts the move the controller is currently performing */ + AIMODULE_API virtual void StopMovement() override; + + /** Called on completing current movement request */ + AIMODULE_API virtual void OnMoveCompleted(FAIRequestID RequestID, const FPathFollowingResult& Result); + + UE_DEPRECATED_FORGAME(4.13, "This function is now deprecated, please use version with EPathFollowingResultDetails parameter.") + AIMODULE_API virtual void OnMoveCompleted(FAIRequestID RequestID, EPathFollowingResult::Type Result); + + /** Returns the Move Request ID for the current move */ + AIMODULE_API FAIRequestID GetCurrentMoveRequestID() const; + + /** Blueprint notification that we've completed the current movement request */ + UPROPERTY(BlueprintAssignable, meta = (DisplayName = "MoveCompleted")) + FAIMoveCompletedSignature ReceiveMoveCompleted; + + TSubclassOf GetDefaultNavigationFilterClass() const { return DefaultNavigationFilterClass; } + + /** Returns status of path following */ + UFUNCTION(BlueprintCallable, Category = "AI|Navigation") + AIMODULE_API EPathFollowingStatus::Type GetMoveStatus() const; + + /** Returns true if the current PathFollowingComponent's path is partial (does not reach desired destination). */ + UFUNCTION(BlueprintCallable, Category = "AI|Navigation") + AIMODULE_API bool HasPartialPath() const; + + /** Returns position of current path segment's end. */ + UFUNCTION(BlueprintCallable, Category = "AI|Navigation") + AIMODULE_API FVector GetImmediateMoveDestination() const; + + /** Updates state of movement block detection. */ + UFUNCTION(BlueprintCallable, Category = "AI|Navigation") + AIMODULE_API void SetMoveBlockDetection(bool bEnable); + + /** Starts executing behavior tree. */ + UFUNCTION(BlueprintCallable, Category = "AI") + AIMODULE_API virtual bool RunBehaviorTree(UBehaviorTree* BTAsset); + +protected: + AIMODULE_API virtual void CleanupBrainComponent(); + + /** Merges the remaining points of InitialPath, with the points of InOutMergedPath. The resulting merged path is outputted into InOutMergedPath. */ + AIMODULE_API virtual void MergePaths(const FNavPathSharedPtr& InitialPath, FNavPathSharedPtr& InOutMergedPath) const; + +public: + /** + * Makes AI use the specified Blackboard asset & creates a Blackboard Component if one does not already exist. + * @param BlackboardAsset The Blackboard asset to use. + * @param BlackboardComponent The Blackboard component that was used or created to work with the passed-in Blackboard Asset. + * @return true if we successfully linked the blackboard asset to the blackboard component. + */ + UFUNCTION(BlueprintCallable, Category = "AI") + AIMODULE_API bool UseBlackboard(UBlackboardData* BlackboardAsset, UBlackboardComponent*& BlackboardComponent); + + /** does this AIController allow given UBlackboardComponent sync data with it */ + AIMODULE_API virtual bool ShouldSyncBlackboardWith(const UBlackboardComponent& OtherBlackboardComponent) const; + + UFUNCTION(BlueprintCallable, Category = "AI|Tasks") + AIMODULE_API void ClaimTaskResource(TSubclassOf ResourceClass); + + UFUNCTION(BlueprintCallable, Category = "AI|Tasks") + AIMODULE_API void UnclaimTaskResource(TSubclassOf ResourceClass); + +protected: + UFUNCTION(BlueprintImplementableEvent) + AIMODULE_API void OnUsingBlackBoard(UBlackboardComponent* BlackboardComp, UBlackboardData* BlackboardAsset); + + AIMODULE_API virtual bool InitializeBlackboard(UBlackboardComponent& BlackboardComp, UBlackboardData& BlackboardAsset); + +public: + /** Retrieve the final position that controller should be looking at. */ + UFUNCTION(BlueprintCallable, Category = "AI") + AIMODULE_API FVector GetFocalPoint() const; + + AIMODULE_API FVector GetFocalPointForPriority(EAIFocusPriority::Type InPriority) const; + + /** Retrieve the focal point this controller should focus to on given actor. */ + UFUNCTION(BlueprintCallable, Category = "AI") + AIMODULE_API virtual FVector GetFocalPointOnActor(const AActor *Actor) const; + + /** Set the position that controller should be looking at. */ + UFUNCTION(BlueprintCallable, Category = "AI", meta = (DisplayName = "SetFocalPoint", ScriptName = "SetFocalPoint", Keywords = "focus")) + AIMODULE_API void K2_SetFocalPoint(FVector FP); + + /** Set Focus for actor, will set FocalPoint as a result. */ + UFUNCTION(BlueprintCallable, Category = "AI", meta = (DisplayName = "SetFocus", ScriptName = "SetFocus")) + AIMODULE_API void K2_SetFocus(AActor* NewFocus); + + /** Get the focused actor. */ + UFUNCTION(BlueprintCallable, Category = "AI") + AIMODULE_API AActor* GetFocusActor() const; + + inline AActor* GetFocusActorForPriority(EAIFocusPriority::Type InPriority) const { return FocusInformation.Priorities.IsValidIndex(InPriority) ? FocusInformation.Priorities[InPriority].Actor.Get() : nullptr; } + + /** Clears Focus, will also clear FocalPoint as a result */ + UFUNCTION(BlueprintCallable, Category = "AI", meta = (DisplayName = "ClearFocus", ScriptName = "ClearFocus")) + AIMODULE_API void K2_ClearFocus(); + + + /** + * Computes a launch velocity vector to toss a projectile and hit the given destination. + * Performance note: Potentially expensive. Nonzero CollisionRadius and bOnlyTraceUp=false are the more expensive options. + * + * @param OutTossVelocity - out param stuffed with the computed velocity to use + * @param Start - desired start point of arc + * @param End - desired end point of arc + * @param TossSpeed - Initial speed of the theoretical projectile. Assumed to only change due to gravity for the entire lifetime of the projectile + * @param CollisionSize (optional) - is the size of bounding box of the tossed actor (defaults to (0,0,0) + * @param bOnlyTraceUp (optional) - when true collision checks verifying the arc will only be done along the upward portion of the arc + * @return - true if a valid arc was computed, false if no valid solution could be found + */ + AIMODULE_API bool SuggestTossVelocity(FVector& OutTossVelocity, FVector Start, FVector End, float TossSpeed, bool bPreferHighArc, float CollisionRadius = 0, bool bOnlyTraceUp = false); + + //~ Begin AActor Interface + AIMODULE_API virtual void Tick(float DeltaTime) override; + AIMODULE_API virtual void PostInitializeComponents() override; + AIMODULE_API virtual void PostRegisterAllComponents() override; + //~ End AActor Interface + + //~ Begin AController Interface +protected: + AIMODULE_API virtual void OnPossess(APawn* InPawn) override; + AIMODULE_API virtual void OnUnPossess() override; + +public: + AIMODULE_API virtual bool ShouldPostponePathUpdates() const override; + AIMODULE_API virtual void DisplayDebug(UCanvas* Canvas, const FDebugDisplayInfo& DebugDisplay, float& YL, float& YPos) override; + +#if ENABLE_VISUAL_LOG + AIMODULE_API virtual void GrabDebugSnapshot(FVisualLogEntry* Snapshot) const override; +#endif + + AIMODULE_API virtual void Reset() override; + + /** + * Checks line to center and top of other actor + * @param Other is the actor whose visibility is being checked. + * @param ViewPoint is eye position visibility is being checked from. If vect(0,0,0) passed in, uses current viewtarget's eye position. + * @param bAlternateChecks used only in AIController implementation + * @return true if controller's pawn can see Other actor. + */ + AIMODULE_API virtual bool LineOfSightTo(const AActor* Other, FVector ViewPoint = FVector(ForceInit), bool bAlternateChecks = false) const override; + //~ End AController Interface + + /** Notifies AIController of changes in given actors' perception */ + AIMODULE_API virtual void ActorsPerceptionUpdated(const TArray& UpdatedActors); + + /** Update direction AI is looking based on FocalPoint */ + AIMODULE_API virtual void UpdateControlRotation(float DeltaTime, bool bUpdatePawn = true); + + /** Set FocalPoint for given priority as absolute position or offset from base. */ + AIMODULE_API virtual void SetFocalPoint(FVector NewFocus, EAIFocusPriority::Type InPriority = EAIFocusPriority::Gameplay); + + /* Set Focus actor for given priority, will set FocalPoint as a result. */ + AIMODULE_API virtual void SetFocus(AActor* NewFocus, EAIFocusPriority::Type InPriority = EAIFocusPriority::Gameplay); + + /** Clears Focus for given priority, will also clear FocalPoint as a result + * @param InPriority focus priority to clear. If you don't know what to use you probably mean EAIFocusPriority::Gameplay*/ + AIMODULE_API virtual void ClearFocus(EAIFocusPriority::Type InPriority); + + AIMODULE_API void SetPerceptionComponent(UAIPerceptionComponent& InPerceptionComponent); + //----------------------------------------------------------------------// + // IAIPerceptionListenerInterface + //----------------------------------------------------------------------// + virtual UAIPerceptionComponent* GetPerceptionComponent() override { return GetAIPerceptionComponent(); } + + //----------------------------------------------------------------------// + // INavAgentInterface + //----------------------------------------------------------------------// + AIMODULE_API virtual bool IsFollowingAPath() const override; + AIMODULE_API virtual IPathFollowingAgentInterface* GetPathFollowingAgent() const override; + + //----------------------------------------------------------------------// + // IGenericTeamAgentInterface + //----------------------------------------------------------------------// +private: + FGenericTeamId TeamID; +public: + AIMODULE_API virtual void SetGenericTeamId(const FGenericTeamId& NewTeamID) override; + virtual FGenericTeamId GetGenericTeamId() const override { return TeamID; } + + //----------------------------------------------------------------------// + // IGameplayTaskOwnerInterface + //----------------------------------------------------------------------// + virtual UGameplayTasksComponent* GetGameplayTasksComponent(const UGameplayTask& Task) const override { return GetGameplayTasksComponent(); } + virtual AActor* GetGameplayTaskOwner(const UGameplayTask* Task) const override { return const_cast(this); } + virtual AActor* GetGameplayTaskAvatar(const UGameplayTask* Task) const override { return GetPawn(); } + virtual uint8 GetGameplayTaskDefaultPriority() const { return FGameplayTasks::DefaultPriority - 1; } + + inline UGameplayTasksComponent* GetGameplayTasksComponent() const { return CachedGameplayTasksComponent; } + + // add empty overrides to fix linker errors if project implements a child class without adding GameplayTasks module dependency + virtual void OnGameplayTaskInitialized(UGameplayTask& Task) override {} + virtual void OnGameplayTaskActivated(UGameplayTask& Task) override {} + virtual void OnGameplayTaskDeactivated(UGameplayTask& Task) override {} + + UFUNCTION() + AIMODULE_API virtual void OnGameplayTaskResourcesClaimed(FGameplayResourceSet NewlyClaimed, FGameplayResourceSet FreshlyReleased); + + //----------------------------------------------------------------------// + // debug/dev-time + //----------------------------------------------------------------------// + AIMODULE_API virtual FString GetDebugIcon() const; + + // Cheat/debugging functions + static void ToggleAIIgnorePlayers() { bAIIgnorePlayers = !bAIIgnorePlayers; } + static bool AreAIIgnoringPlayers() { return bAIIgnorePlayers; } + + /** If true, AI controllers will ignore players. */ + static AIMODULE_API bool bAIIgnorePlayers; + +public: + /** Returns PathFollowingComponent subobject **/ + UFUNCTION(BlueprintCallable, Category="AI|Navigation") + UPathFollowingComponent* GetPathFollowingComponent() const { return PathFollowingComponent; } + UFUNCTION(BlueprintPure, Category = "AI|Perception") + UAIPerceptionComponent* GetAIPerceptionComponent() { return PerceptionComponent; } + + const UAIPerceptionComponent* GetAIPerceptionComponent() const { return PerceptionComponent; } + + UBrainComponent* GetBrainComponent() const { return BrainComponent; } + const UBlackboardComponent* GetBlackboardComponent() const { return Blackboard; } + UBlackboardComponent* GetBlackboardComponent() { return Blackboard; } + + /** Note that this function does not do any pathfollowing state transfer. + * Intended to be called as part of initialization/setup process */ + UFUNCTION(BlueprintCallable, Category = "AI|Navigation") + AIMODULE_API void SetPathFollowingComponent(UPathFollowingComponent* NewPFComponent); +}; + +//----------------------------------------------------------------------// +// forceinlines +//----------------------------------------------------------------------// +namespace FAISystem +{ + inline bool IsValidControllerAndHasValidPawn(const AController* Controller) + { + return Controller != nullptr && Controller->IsPendingKillPending() == false + && Controller->GetPawn() != nullptr && Controller->GetPawn()->IsPendingKillPending() == false; + } +} diff --git a/samples/GameplayTagsManager.h b/samples/GameplayTagsManager.h new file mode 100644 index 0000000..68eb7c9 --- /dev/null +++ b/samples/GameplayTagsManager.h @@ -0,0 +1,1010 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "AssetRegistry/AssetData.h" +#include "CoreMinimal.h" +#include "Stats/Stats.h" +#include "UObject/ObjectMacros.h" +#include "UObject/Object.h" +#include "UObject/ScriptMacros.h" +#include "GameplayTagContainer.h" +#include "Engine/DataTable.h" +#include "Templates/UniquePtr.h" +#include "Misc/ScopeLock.h" +#include "Misc/TransactionallySafeCriticalSection.h" +#if WITH_EDITOR +#include "Hash/Blake3.h" +#endif + +#include "GameplayTagsManager.generated.h" + +class UGameplayTagsList; +struct FStreamableHandle; +class FNativeGameplayTag; + +#if WITH_EDITOR +namespace UE::Cook { class FCookDependency; } +namespace UE::Cook { class ICookInfo; } +#endif + +/** Simple struct for a table row in the gameplay tag table and element in the ini list */ +USTRUCT() +struct FGameplayTagTableRow : public FTableRowBase +{ + GENERATED_USTRUCT_BODY() + + /** Tag specified in the table */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=GameplayTag) + FName Tag; + + /** Developer comment clarifying the usage of a particular tag, not user facing */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=GameplayTag) + FString DevComment; + + /** Constructors */ + FGameplayTagTableRow() {} + FGameplayTagTableRow(FName InTag, const FString& InDevComment = TEXT("")) : Tag(InTag), DevComment(InDevComment) {} + GAMEPLAYTAGS_API FGameplayTagTableRow(FGameplayTagTableRow const& Other); + + /** Assignment/Equality operators */ + GAMEPLAYTAGS_API FGameplayTagTableRow& operator=(FGameplayTagTableRow const& Other); + GAMEPLAYTAGS_API bool operator==(FGameplayTagTableRow const& Other) const; + GAMEPLAYTAGS_API bool operator!=(FGameplayTagTableRow const& Other) const; + GAMEPLAYTAGS_API bool operator<(FGameplayTagTableRow const& Other) const; +}; + +/** Simple struct for a table row in the restricted gameplay tag table and element in the ini list */ +USTRUCT() +struct FRestrictedGameplayTagTableRow : public FGameplayTagTableRow +{ + GENERATED_USTRUCT_BODY() + + /** Tag specified in the table */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = GameplayTag) + bool bAllowNonRestrictedChildren; + + /** Constructors */ + FRestrictedGameplayTagTableRow() : bAllowNonRestrictedChildren(false) {} + FRestrictedGameplayTagTableRow(FName InTag, const FString& InDevComment = TEXT(""), bool InAllowNonRestrictedChildren = false) : FGameplayTagTableRow(InTag, InDevComment), bAllowNonRestrictedChildren(InAllowNonRestrictedChildren) {} + GAMEPLAYTAGS_API FRestrictedGameplayTagTableRow(FRestrictedGameplayTagTableRow const& Other); + + /** Assignment/Equality operators */ + GAMEPLAYTAGS_API FRestrictedGameplayTagTableRow& operator=(FRestrictedGameplayTagTableRow const& Other); + GAMEPLAYTAGS_API bool operator==(FRestrictedGameplayTagTableRow const& Other) const; + GAMEPLAYTAGS_API bool operator!=(FRestrictedGameplayTagTableRow const& Other) const; +}; + +UENUM() +enum class EGameplayTagSourceType : uint8 +{ + Native, // Was added from C++ code + DefaultTagList, // The default tag list in DefaultGameplayTags.ini + TagList, // Another tag list from an ini in tags/*.ini + RestrictedTagList, // Restricted tags from an ini + DataTable, // From a DataTable + Invalid, // Not a real source +}; + +UENUM() +enum class EGameplayTagSelectionType : uint8 +{ + None, + NonRestrictedOnly, + RestrictedOnly, + All +}; + +/** Struct defining where gameplay tags are loaded/saved from. Mostly for the editor */ +USTRUCT() +struct FGameplayTagSource +{ + GENERATED_USTRUCT_BODY() + + /** Name of this source */ + UPROPERTY() + FName SourceName; + + /** Type of this source */ + UPROPERTY() + EGameplayTagSourceType SourceType; + + /** If this is bound to an ini object for saving, this is the one */ + UPROPERTY() + TObjectPtr SourceTagList; + + /** If this has restricted tags and is bound to an ini object for saving, this is the one */ + UPROPERTY() + TObjectPtr SourceRestrictedTagList; + + FGameplayTagSource() + : SourceName(NAME_None), SourceType(EGameplayTagSourceType::Invalid), SourceTagList(nullptr), SourceRestrictedTagList(nullptr) + { + } + + FGameplayTagSource(FName InSourceName, EGameplayTagSourceType InSourceType, UGameplayTagsList* InSourceTagList = nullptr, URestrictedGameplayTagsList* InSourceRestrictedTagList = nullptr) + : SourceName(InSourceName), SourceType(InSourceType), SourceTagList(InSourceTagList), SourceRestrictedTagList(InSourceRestrictedTagList) + { + } + + /** Returns the config file that created this source, if valid */ + GAMEPLAYTAGS_API FString GetConfigFileName() const; + + static GAMEPLAYTAGS_API FName GetNativeName(); + + static GAMEPLAYTAGS_API FName GetDefaultName(); + +#if WITH_EDITOR + static GAMEPLAYTAGS_API FName GetFavoriteName(); + + static GAMEPLAYTAGS_API void SetFavoriteName(FName TagSourceToFavorite); + + static GAMEPLAYTAGS_API FName GetTransientEditorName(); +#endif +}; + +/** Struct describing the places to look for ini search paths */ +struct FGameplayTagSearchPathInfo +{ + /** Which sources should be loaded from this path */ + TArray SourcesInPath; + + /** Config files to load from, will normally correspond to FoundSources */ + TArray TagIniList; + + /** True if this path has already been searched */ + bool bWasSearched = false; + + /** True if the tags in sources have been added to the current tree */ + bool bWasAddedToTree = false; + + inline void Reset() + { + SourcesInPath.Reset(); + TagIniList.Reset(); + bWasSearched = false; + bWasAddedToTree = false; + } + + inline bool IsValid() + { + return bWasSearched && bWasAddedToTree; + } +}; + +/** Simple tree node for gameplay tags, this stores metadata about specific tags */ +USTRUCT() +struct FGameplayTagNode +{ + GENERATED_USTRUCT_BODY() + FGameplayTagNode(){}; + + /** Simple constructor, passing redundant data for performance */ + FGameplayTagNode(FName InTag, FName InFullTag, TSharedPtr InParentNode, bool InIsExplicitTag, bool InIsRestrictedTag, bool InAllowNonRestrictedChildren); + + /** Returns a correctly constructed container with only this tag, useful for doing container queries */ + inline const FGameplayTagContainer& GetSingleTagContainer() const { return CompleteTagWithParents; } + + /** + * Get the complete tag for the node, including all parent tags, delimited by periods + * + * @return Complete tag for the node + */ + inline const FGameplayTag& GetCompleteTag() const { return CompleteTagWithParents.Num() > 0 ? CompleteTagWithParents.GameplayTags[0] : FGameplayTag::EmptyTag; } + inline FName GetCompleteTagName() const { return GetCompleteTag().GetTagName(); } + inline FString GetCompleteTagString() const { return GetCompleteTag().ToString(); } + + /** + * Get the simple tag for the node (doesn't include any parent tags) + * + * @return Simple tag for the node + */ + inline FName GetSimpleTagName() const { return Tag; } + + /** + * Get the children nodes of this node + * + * @return Reference to the array of the children nodes of this node + */ + inline TArray< TSharedPtr >& GetChildTagNodes() { return ChildTags; } + + /** + * Get the children nodes of this node + * + * @return Reference to the array of the children nodes of this node + */ + inline const TArray< TSharedPtr >& GetChildTagNodes() const { return ChildTags; } + + /** + * Get the parent tag node of this node + * + * @return The parent tag node of this node + */ + inline TSharedPtr GetParentTagNode() const { return ParentNode; } + + /** + * Get the net index of this node + * + * @return The net index of this node + */ + inline FGameplayTagNetIndex GetNetIndex() const { check(NetIndex != INVALID_TAGNETINDEX); return NetIndex; } + + /** Reset the node of all of its values */ + GAMEPLAYTAGS_API void ResetNode(); + + /** Returns true if the tag was explicitly specified in code or data */ + inline bool IsExplicitTag() const { +#if WITH_EDITORONLY_DATA + return bIsExplicitTag; +#else + return true; +#endif + } + + /** Returns true if the tag is a restricted tag and allows non-restricted children */ + inline bool GetAllowNonRestrictedChildren() const { +#if WITH_EDITORONLY_DATA + return bAllowNonRestrictedChildren; +#else + return true; +#endif + } + + /** Returns true if the tag is a restricted tag */ + inline bool IsRestrictedGameplayTag() const { +#if WITH_EDITORONLY_DATA + return bIsRestrictedTag; +#else + return true; +#endif + } + +#if WITH_EDITORONLY_DATA + FName GetFirstSourceName() const { return SourceNames.Num() == 0 ? NAME_None : SourceNames[0]; } + const TArray& GetAllSourceNames() const { return SourceNames; } +#endif + +#if WITH_EDITORONLY_DATA + /** Returns the Comment for this tag */ + FString GetDevComment() const { return DevComment; } +#endif + +#if WITH_EDITOR + /** + * Update the hasher with a deterministic hash of the data on this. Used for e.g. IncrementalCook keys. + * Does not include data from this node's child or parent nodes. + */ + GAMEPLAYTAGS_API void Hash(FBlake3& Hasher); +#endif + +private: + /** Raw name for this tag at current rank in the tree */ + FName Tag; + + /** This complete tag is at GameplayTags[0], with parents in ParentTags[] */ + FGameplayTagContainer CompleteTagWithParents; + + /** Child gameplay tag nodes */ + TArray< TSharedPtr > ChildTags; + + /** Owner gameplay tag node, if any */ + TSharedPtr ParentNode; + + /** Net Index of this node */ + FGameplayTagNetIndex NetIndex; + +#if WITH_EDITORONLY_DATA + /** Module or Package or config file this tag came from. If empty this is an implicitly added tag */ + TArray SourceNames; + + /** Comment for this tag */ + FString DevComment; + + /** If this is true then the tag can only have normal tag children if bAllowNonRestrictedChildren is true */ + uint8 bIsRestrictedTag : 1; + + /** If this is true then any children of this tag must come from the restricted tags */ + uint8 bAllowNonRestrictedChildren : 1; + + /** If this is true then the tag was explicitly added and not only implied by its child tags */ + uint8 bIsExplicitTag : 1; + + /** If this is true then at least one tag that inherits from this tag is coming from multiple sources. Used for updating UI in the editor. */ + uint8 bDescendantHasConflict : 1; + + /** If this is true then this tag is coming from multiple sources. No descendants can be changed on this tag until this is resolved. */ + uint8 bNodeHasConflict : 1; + + /** If this is true then at least one tag that this tag descends from is coming from multiple sources. This tag and it's descendants can't be changed in the editor. */ + uint8 bAncestorHasConflict : 1; +#endif + + friend class UGameplayTagsManager; + friend class SGameplayTagWidget; + friend class SGameplayTagPicker; +}; + +/** Holds data about the tag dictionary, is in a singleton UObject */ +UCLASS(config=Engine, MinimalAPI) +class UGameplayTagsManager : public UObject +{ + GENERATED_UCLASS_BODY() + + /** Destructor */ + GAMEPLAYTAGS_API ~UGameplayTagsManager(); + + /** Returns the global UGameplayTagsManager manager */ + inline static UGameplayTagsManager& Get() + { + if (SingletonManager == nullptr) + { + InitializeManager(); + } + + return *SingletonManager; + } + + /** Returns possibly nullptr to the manager. Needed for some shutdown cases to avoid reallocating. */ + inline static UGameplayTagsManager* GetIfAllocated() { return SingletonManager; } + + /** + * Adds the gameplay tags corresponding to the strings in the array TagStrings to OutTagsContainer + * + * @param TagStrings Array of strings to search for as tags to add to the tag container + * @param OutTagsContainer Container to add the found tags to. + * @param ErrorIfNotfound: ensure() that tags exists. + * + */ + GAMEPLAYTAGS_API void RequestGameplayTagContainer(const TArray& TagStrings, FGameplayTagContainer& OutTagsContainer, bool bErrorIfNotFound=true) const; + + /** + * Gets the FGameplayTag that corresponds to the TagName + * + * @param TagName The Name of the tag to search for + * @param ErrorIfNotfound: ensure() that tag exists. + * + * @return Will return the corresponding FGameplayTag or an empty one if not found. + */ + GAMEPLAYTAGS_API FGameplayTag RequestGameplayTag(FName TagName, bool ErrorIfNotFound=true) const; + + /** + * Returns true if this is a valid gameplay tag string (foo.bar.baz). If false, it will fill + * @param TagString String to check for validity + * @param OutError If non-null and string invalid, will fill in with an error message + * @param OutFixedString If non-null and string invalid, will attempt to fix. Will be empty if no fix is possible + * @return True if this can be added to the tag dictionary, false if there's a syntax error + */ + GAMEPLAYTAGS_API bool IsValidGameplayTagString(const TCHAR* TagString, FText* OutError = nullptr, FString* OutFixedString = nullptr); + GAMEPLAYTAGS_API bool IsValidGameplayTagString(const FString& TagString, FText* OutError = nullptr, FString* OutFixedString = nullptr); + GAMEPLAYTAGS_API bool IsValidGameplayTagString(const FStringView& TagString, FText* OutError = nullptr, FStringBuilderBase* OutFixedString = nullptr); + + /** + * Searches for a gameplay tag given a partial string. This is slow and intended mainly for console commands/utilities to make + * developer life's easier. This will attempt to match as best as it can. If you pass "A.b" it will match on "A.b." before it matches "a.b.c". + */ + GAMEPLAYTAGS_API FGameplayTag FindGameplayTagFromPartialString_Slow(FString PartialString) const; + + /** + * Registers the given name as a gameplay tag, and tracks that it is being directly referenced from code + * This can only be called during engine initialization, the table needs to be locked down before replication + * + * @param TagName The Name of the tag to add + * @param TagDevComment The developer comment clarifying the usage of the tag + * + * @return Will return the corresponding FGameplayTag + */ + GAMEPLAYTAGS_API FGameplayTag AddNativeGameplayTag(FName TagName, const FString& TagDevComment = TEXT("(Native)")); + +private: + // Only callable from FNativeGameplayTag, these functions do less error checking and can happen after initial tag loading is done + GAMEPLAYTAGS_API void AddNativeGameplayTag(FNativeGameplayTag* TagSource); + GAMEPLAYTAGS_API void RemoveNativeGameplayTag(const FNativeGameplayTag* TagSource); + +public: + /** Call to flush the list of native tags, once called it is unsafe to add more */ + GAMEPLAYTAGS_API void DoneAddingNativeTags(); + + /** This is a delegate that is called during initialization/initial loading and signals the last chance to add tags before we are considered to be fully loaded (all tags registered). */ + static GAMEPLAYTAGS_API FSimpleMulticastDelegate& OnLastChanceToAddNativeTags(); + + /** + * Register a callback for when native tags are done being added (this is also a safe point to consider that the gameplay tags have fully been initialized). + * Or, if the native tags have already been added (and thus we have registered all valid tags), then execute this Delegate immediately. + * This is useful if your code is potentially executed during load time, and therefore any tags in your block of code could be not-yet-loaded, but possibly valid after being loaded. + */ + GAMEPLAYTAGS_API FDelegateHandle CallOrRegister_OnDoneAddingNativeTagsDelegate(const FSimpleMulticastDelegate::FDelegate& Delegate) const; + + /** + * Gets a Tag Container containing the supplied tag and all of its parents as explicit tags. + * For example, passing in x.y.z would return a tag container with x.y.z, x.y, and x. + * This will only work for tags that have been properly registered. + * + * @param GameplayTag The tag to use at the child most tag for this container + * + * @return A tag container with the supplied tag and all its parents added explicitly, or an empty container if that failed + */ + GAMEPLAYTAGS_API FGameplayTagContainer RequestGameplayTagParents(const FGameplayTag& GameplayTag) const; + + /** + * Fills in an array of gameplay tags with all of tags that are the parents of the passed in tag. + * For example, passing in x.y.z would add x.y and x to UniqueParentTags if they was not already there. + * This is used by the GameplayTagContainer code and may work for unregistered tags depending on serialization settings. + * + * @param GameplayTag The gameplay tag to extract parent tags from + * @param UniqueParentTags A list of parent tags that will be added to if necessary + * + * @return true if any tags were added to UniqueParentTags + */ + GAMEPLAYTAGS_API bool ExtractParentTags(const FGameplayTag& GameplayTag, TArray& UniqueParentTags) const; + + /** + * Gets a Tag Container containing the all tags in the hierarchy that are children of this tag. Does not return the original tag + * + * @param GameplayTag The Tag to use at the parent tag + * + * @return A Tag Container with the supplied tag and all its parents added explicitly + */ + GAMEPLAYTAGS_API FGameplayTagContainer RequestGameplayTagChildren(const FGameplayTag& GameplayTag) const; + + /** Returns direct parent GameplayTag of this GameplayTag, calling on x.y will return x */ + GAMEPLAYTAGS_API FGameplayTag RequestGameplayTagDirectParent(const FGameplayTag& GameplayTag) const; + + UE_DEPRECATED(5.4, "This function is not threadsafe, use FindTagNode or FGameplayTag::GetSingleTagContainer") + inline const FGameplayTagContainer* GetSingleTagContainer(const FGameplayTag& GameplayTag) const + { + return GetSingleTagContainerPtr(GameplayTag); + } + + /** + * Checks node tree to see if a FGameplayTagNode with the tag exists + * + * @param TagName The name of the tag node to search for + * + * @return A shared pointer to the FGameplayTagNode found, or NULL if not found. + */ + inline TSharedPtr FindTagNode(const FGameplayTag& GameplayTag) const + { + UE::TScopeLock Lock(GameplayTagMapCritical); + + const TSharedPtr* Node = GameplayTagNodeMap.Find(GameplayTag); + + if (Node) + { + return *Node; + } +#if WITH_EDITOR + // Check redirector + if (GIsEditor && GameplayTag.IsValid()) + { + FGameplayTag RedirectedTag = GameplayTag; + + RedirectSingleGameplayTag(RedirectedTag, nullptr); + + Node = GameplayTagNodeMap.Find(RedirectedTag); + + if (Node) + { + return *Node; + } + } +#endif + return nullptr; + } + + /** + * Checks node tree to see if a FGameplayTagNode with the name exists + * + * @param TagName The name of the tag node to search for + * + * @return A shared pointer to the FGameplayTagNode found, or NULL if not found. + */ + inline TSharedPtr FindTagNode(FName TagName) const + { + FGameplayTag PossibleTag(TagName); + return FindTagNode(PossibleTag); + } + + /** Loads the tag tables referenced in the GameplayTagSettings object */ + GAMEPLAYTAGS_API void LoadGameplayTagTables(bool bAllowAsyncLoad = false); + + /** Loads tag inis contained in the specified path, passes an optional PluginConfigCache to speed up disk searches */ + GAMEPLAYTAGS_API void AddTagIniSearchPath(const FString& RootDir, const TSet* PluginConfigsCache = nullptr); + + /** Tries to remove the specified search path, will return true if anything was removed */ + GAMEPLAYTAGS_API bool RemoveTagIniSearchPath(const FString& RootDir); + + /** Gets all the current directories to look for tag sources in */ + GAMEPLAYTAGS_API void GetTagSourceSearchPaths(TArray& OutPaths); + + /** Gets the number of tag source search paths */ + GAMEPLAYTAGS_API int32 GetNumTagSourceSearchPaths(); + + /** Helper function to construct the gameplay tag tree */ + GAMEPLAYTAGS_API void ConstructGameplayTagTree(); + + /** Helper function to destroy the gameplay tag tree */ + GAMEPLAYTAGS_API void DestroyGameplayTagTree(); + + /** Splits a tag such as x.y.z into an array of names {x,y,z} */ + GAMEPLAYTAGS_API void SplitGameplayTagFName(const FGameplayTag& Tag, TArray& OutNames) const; + + /** Gets the list of all registered tags, setting OnlyIncludeDictionaryTags will exclude implicitly added tags if possible */ + GAMEPLAYTAGS_API void RequestAllGameplayTags(FGameplayTagContainer& TagContainer, bool OnlyIncludeDictionaryTags) const; + + /** Returns true if if the passed in name is in the tag dictionary and can be created */ + GAMEPLAYTAGS_API bool ValidateTagCreation(FName TagName) const; + + /** Returns the tag source for a given tag source name and type, or null if not found */ + GAMEPLAYTAGS_API const FGameplayTagSource* FindTagSource(FName TagSourceName) const; + + /** Returns the tag source for a given tag source name and type, or null if not found */ + GAMEPLAYTAGS_API FGameplayTagSource* FindTagSource(FName TagSourceName); + + /** Fills in an array with all tag sources of a specific type */ + GAMEPLAYTAGS_API void FindTagSourcesWithType(EGameplayTagSourceType TagSourceType, TArray& OutArray) const; + + GAMEPLAYTAGS_API void FindTagsWithSource(FStringView PackageNameOrPath, TArray& OutTags) const; + + /** + * Check to see how closely two FGameplayTags match. Higher values indicate more matching terms in the tags. + * + * @param GameplayTagOne The first tag to compare + * @param GameplayTagTwo The second tag to compare + * + * @return the length of the longest matching pair + */ + GAMEPLAYTAGS_API int32 GameplayTagsMatchDepth(const FGameplayTag& GameplayTagOne, const FGameplayTag& GameplayTagTwo) const; + + /** Returns the number of parents a particular gameplay tag has. Useful as a quick way to determine which tags may + * be more "specific" than other tags without comparing whether they are in the same hierarchy or anything else. + * Example: "TagA.SubTagA" has 2 Tag Nodes. "TagA.SubTagA.LeafTagA" has 3 Tag Nodes. + */ + GAMEPLAYTAGS_API int32 GetNumberOfTagNodes(const FGameplayTag& GameplayTag) const; + + /** Returns true if we should import tags from UGameplayTagsSettings objects (configured by INI files) */ + GAMEPLAYTAGS_API bool ShouldImportTagsFromINI() const; + + /** Should we print loading errors when trying to load invalid tags */ + bool ShouldWarnOnInvalidTags() const + { + return bShouldWarnOnInvalidTags; + } + + /** Should we clear references to invalid tags loaded/saved in the editor */ + UE_DEPRECATED(5.5, "We should never clear invalid tags as we're not guaranteed the required plugin has loaded") + bool ShouldClearInvalidTags() const + { + return false; + } + + /** Should use fast replication */ + bool ShouldUseFastReplication() const + { + return bUseFastReplication; + } + + /** Should use dynamic replication (Gameplay Tags need not match between client/server) */ + bool ShouldUseDynamicReplication() const + { + return !bUseFastReplication && bUseDynamicReplication; + } + + /** If we are allowed to unload tags */ + GAMEPLAYTAGS_API bool ShouldUnloadTags() const; + + /** Pushes an override that supersedes bShouldAllowUnloadingTags to allow/disallow unloading of GameplayTags in controlled scenarios */ + GAMEPLAYTAGS_API void SetShouldUnloadTagsOverride(bool bShouldUnloadTags); + + /** Clears runtime overrides, reverting to bShouldAllowUnloadingTags when determining GameplayTags unload behavior */ + GAMEPLAYTAGS_API void ClearShouldUnloadTagsOverride(); + + /** Pushes an override that suppresses calls to HandleGameplayTagTreeChanged that would result in a complete rebuild of the GameplayTag tree */ + GAMEPLAYTAGS_API void SetShouldDeferGameplayTagTreeRebuilds(bool bShouldDeferRebuilds); + + /** Stops suppressing GameplayTag tree rebuilds and (optionally) rebuilds the tree */ + GAMEPLAYTAGS_API void ClearShouldDeferGameplayTagTreeRebuilds(bool bRebuildTree); + + /** Returns the hash of NetworkGameplayTagNodeIndex */ + uint32 GetNetworkGameplayTagNodeIndexHash() const { VerifyNetworkIndex(); return NetworkGameplayTagNodeIndexHash; } + + /** Returns a list of the ini files that contain restricted tags */ + GAMEPLAYTAGS_API void GetRestrictedTagConfigFiles(TArray& RestrictedConfigFiles) const; + + /** Returns a list of the source files that contain restricted tags */ + GAMEPLAYTAGS_API void GetRestrictedTagSources(TArray& Sources) const; + + /** Returns a list of the owners for a restricted tag config file. May be empty */ + GAMEPLAYTAGS_API void GetOwnersForTagSource(const FString& SourceName, TArray& OutOwners) const; + + /** Notification that a tag container has been loaded via serialize */ + GAMEPLAYTAGS_API void GameplayTagContainerLoaded(FGameplayTagContainer& Container, FProperty* SerializingProperty) const; + + /** Notification that a gameplay tag has been loaded via serialize */ + GAMEPLAYTAGS_API void SingleGameplayTagLoaded(FGameplayTag& Tag, FProperty* SerializingProperty) const; + + /** Handles redirectors for an entire container, will also error on invalid tags */ + GAMEPLAYTAGS_API void RedirectTagsForContainer(FGameplayTagContainer& Container, FProperty* SerializingProperty) const; + + /** Handles redirectors for a single tag, will also error on invalid tag. This is only called for when individual tags are serialized on their own */ + GAMEPLAYTAGS_API void RedirectSingleGameplayTag(FGameplayTag& Tag, FProperty* SerializingProperty) const; + + /** Handles establishing a single tag from an imported tag name (accounts for redirects too). Called when tags are imported via text. */ + GAMEPLAYTAGS_API bool ImportSingleGameplayTag(FGameplayTag& Tag, FName ImportedTagName, bool bImportFromSerialize = false) const; + + /** Gets a tag name from net index and vice versa, used for replication efficiency */ + GAMEPLAYTAGS_API FName GetTagNameFromNetIndex(FGameplayTagNetIndex Index) const; + GAMEPLAYTAGS_API FGameplayTagNetIndex GetNetIndexFromTag(const FGameplayTag &InTag) const; + + /** Cached number of bits we need to replicate tags. That is, Log2(Number of Tags). Will always be <= 16. */ + int32 GetNetIndexTrueBitNum() const { VerifyNetworkIndex(); return NetIndexTrueBitNum; } + + /** The length in bits of the first segment when net serializing tags. We will serialize NetIndexFirstBitSegment + 1 bit to indicatore "more" (more = second segment that is NetIndexTrueBitNum - NetIndexFirstBitSegment) */ + int32 GetNetIndexFirstBitSegment() const { VerifyNetworkIndex(); return NetIndexFirstBitSegment; } + + /** This is the actual value for an invalid tag "None". This is computed at runtime as (Total number of tags) + 1 */ + FGameplayTagNetIndex GetInvalidTagNetIndex() const { VerifyNetworkIndex(); return InvalidTagNetIndex; } + + const TArray>& GetNetworkGameplayTagNodeIndex() const { VerifyNetworkIndex(); return NetworkGameplayTagNodeIndex; } + + DECLARE_TS_MULTICAST_DELEGATE_OneParam(FOnGameplayTagLoaded, const FGameplayTag& /*Tag*/) + FOnGameplayTagLoaded OnGameplayTagLoadedDelegate; + + /** Numbers of bits to use for replicating container size. This can be set via config. */ + int32 NumBitsForContainerSize; + + GAMEPLAYTAGS_API void PushDeferOnGameplayTagTreeChangedBroadcast(); + GAMEPLAYTAGS_API void PopDeferOnGameplayTagTreeChangedBroadcast(); + +private: + /** Cached number of bits we need to replicate tags. That is, Log2(Number of Tags). Will always be <= 16. */ + int32 NetIndexTrueBitNum; + + /** The length in bits of the first segment when net serializing tags. We will serialize NetIndexFirstBitSegment + 1 bit to indicatore "more" (more = second segment that is NetIndexTrueBitNum - NetIndexFirstBitSegment) */ + int32 NetIndexFirstBitSegment; + + /** This is the actual value for an invalid tag "None". This is computed at runtime as (Total number of tags) + 1 */ + FGameplayTagNetIndex InvalidTagNetIndex; + +public: + +#if WITH_EDITOR + /** Gets a Filtered copy of the GameplayRootTags Array based on the comma delimited filter string passed in */ + GAMEPLAYTAGS_API void GetFilteredGameplayRootTags(const FString& InFilterString, TArray< TSharedPtr >& OutTagArray) const; + + /** Returns "Categories" meta property from given handle, used for filtering by tag widget */ + GAMEPLAYTAGS_API FString GetCategoriesMetaFromPropertyHandle(TSharedPtr PropertyHandle) const; + + /** Helper function, made to be called by custom OnGetCategoriesMetaFromPropertyHandle handlers */ + static GAMEPLAYTAGS_API FString StaticGetCategoriesMetaFromPropertyHandle(TSharedPtr PropertyHandle); + + /** Returns "Categories" meta property from given field, used for filtering by tag widget */ + template + static FString GetCategoriesMetaFromField(TFieldType* Field) + { + check(Field); + if (Field->HasMetaData(NAME_Categories)) + { + return Field->GetMetaData(NAME_Categories); + } + else if (Field->HasMetaData(NAME_GameplayTagFilter)) + { + return Field->GetMetaData(NAME_GameplayTagFilter); + } + return FString(); + } + + /** Returns "GameplayTagFilter" meta property from given function, used for filtering by tag widget for any parameters of the function that end up as BP pins */ + static GAMEPLAYTAGS_API FString GetCategoriesMetaFromFunction(const UFunction* Func, FName ParamName = NAME_None); + + /** Gets a list of all gameplay tag nodes added by the specific source */ + GAMEPLAYTAGS_API void GetAllTagsFromSource(FName TagSource, TArray< TSharedPtr >& OutTagArray) const; + + /** Returns true if this tag was explicitly registered, this is false for implictly added parent tags */ + GAMEPLAYTAGS_API bool IsDictionaryTag(FName TagName) const; + + /** Returns information about tag. If not found return false */ + GAMEPLAYTAGS_API bool GetTagEditorData(FName TagName, FString& OutComment, FName &OutFirstTagSource, bool& bOutIsTagExplicit, bool &bOutIsRestrictedTag, bool &bOutAllowNonRestrictedChildren) const; + + /** Returns information about tag. If not found return false */ + GAMEPLAYTAGS_API bool GetTagEditorData(FName TagName, FString& OutComment, TArray& OutTagSources, bool& bOutIsTagExplicit, bool &bOutIsRestrictedTag, bool &bOutAllowNonRestrictedChildren) const; + + /** This is called after EditorRefreshGameplayTagTree. Useful if you need to do anything editor related when tags are added or removed */ + static GAMEPLAYTAGS_API FSimpleMulticastDelegate OnEditorRefreshGameplayTagTree; + + /** Refresh the gameplaytag tree due to an editor change */ + GAMEPLAYTAGS_API void EditorRefreshGameplayTagTree(); + + /** Suspends EditorRefreshGameplayTagTree requests */ + GAMEPLAYTAGS_API void SuspendEditorRefreshGameplayTagTree(FGuid SuspendToken); + + /** Resumes EditorRefreshGameplayTagTree requests; triggers a refresh if a request was made while it was suspended */ + GAMEPLAYTAGS_API void ResumeEditorRefreshGameplayTagTree(FGuid SuspendToken); + + /** Gets a Tag Container containing all of the tags in the hierarchy that are children of this tag, and were explicitly added to the dictionary */ + GAMEPLAYTAGS_API FGameplayTagContainer RequestGameplayTagChildrenInDictionary(const FGameplayTag& GameplayTag) const; +#if WITH_EDITORONLY_DATA + /** Gets a Tag Container containing all of the tags in the hierarchy that are children of this tag, were explicitly added to the dictionary, and do not have any explicitly added tags between them and the specified tag */ + GAMEPLAYTAGS_API FGameplayTagContainer RequestGameplayTagDirectDescendantsInDictionary(const FGameplayTag& GameplayTag, EGameplayTagSelectionType SelectionType) const; +#endif // WITH_EDITORONLY_DATA + + + DECLARE_MULTICAST_DELEGATE_TwoParams(FOnGameplayTagDoubleClickedEditor, FGameplayTag, FSimpleMulticastDelegate& /* OUT */) + FOnGameplayTagDoubleClickedEditor OnGatherGameplayTagDoubleClickedEditor; + + /** Chance to dynamically change filter string based on a property handle */ + DECLARE_MULTICAST_DELEGATE_TwoParams(FOnGetCategoriesMetaFromPropertyHandle, TSharedPtr, FString& /* OUT */) + FOnGetCategoriesMetaFromPropertyHandle OnGetCategoriesMetaFromPropertyHandle; + + /** Allows dynamic hiding of gameplay tags in SGameplayTagWidget. Allows higher order structs to dynamically change which tags are visible based on its own data */ + DECLARE_MULTICAST_DELEGATE_ThreeParams(FOnFilterGameplayTagChildren, const FString& /** FilterString */, TSharedPtr& /* TagNode */, bool& /* OUT OutShouldHide */) + FOnFilterGameplayTagChildren OnFilterGameplayTagChildren; + + /* + * This is a container to filter out gameplay tags when they are invalid or when they don't meet the filter string + * If used from editor to filter out tags when picking them the FilterString is optional and the ReferencingPropertyHandle is required + * If used to validate an asset / assets you can provide the TagSourceAssets. The FilterString and ReferencingPropertyHandle is optional + */ + struct FFilterGameplayTagContext + { + const FString& FilterString; + const TSharedPtr& TagNode; + const FGameplayTagSource* TagSource; + const TSharedPtr ReferencingPropertyHandle; + const TArray TagSourceAssets; + + FFilterGameplayTagContext(const FString& InFilterString, const TSharedPtr& InTagNode, const FGameplayTagSource* InTagSource, const TSharedPtr& InReferencingPropertyHandle) + : FilterString(InFilterString), TagNode(InTagNode), TagSource(InTagSource), ReferencingPropertyHandle(InReferencingPropertyHandle) + {} + + FFilterGameplayTagContext(const TSharedPtr& InTagNode, const FGameplayTagSource* InTagSource, const TArray& InTagSourceAssets, const FString& InFilterString = FString()) + : FilterString(InFilterString), TagNode(InTagNode), TagSource(InTagSource), TagSourceAssets(InTagSourceAssets) + {} + }; + + /* + * Allows dynamic hiding of gameplay tags in SGameplayTagWidget. Allows higher order structs to dynamically change which tags are visible based on its own data + * Applies to all tags, and has more context than OnFilterGameplayTagChildren + */ + DECLARE_MULTICAST_DELEGATE_TwoParams(FOnFilterGameplayTag, const FFilterGameplayTagContext& /** InContext */, bool& /* OUT OutShouldHide */) + FOnFilterGameplayTag OnFilterGameplayTag; + + GAMEPLAYTAGS_API void NotifyGameplayTagDoubleClickedEditor(FString TagName); + + GAMEPLAYTAGS_API bool ShowGameplayTagAsHyperLinkEditor(FString TagName); + + /** + * Used for incremental cooking. Create an FCookDependency that reports tags that have been read from ini. + * Packages that pass this dependency to AddCookLoadDependency or AddCookSaveDependency in their OnCookEvent or + * (if Ar.IsCooking()) Serialize function will be invalidated and recooked by the incremental cook whenever those + * tags change. + */ + GAMEPLAYTAGS_API UE::Cook::FCookDependency CreateCookDependency(); + + /** Implementation of console command GameplayTags.DumpSources */ + void DumpSources(FOutputDevice& Out) const; +#endif //WITH_EDITOR + + GAMEPLAYTAGS_API void PrintReplicationIndices(); + int32 GetNumGameplayTagNodes() const { return GameplayTagNodeMap.Num(); } + +#if !(UE_BUILD_SHIPPING || UE_BUILD_TEST) + /** Mechanism for tracking what tags are frequently replicated */ + + GAMEPLAYTAGS_API void PrintReplicationFrequencyReport(); + GAMEPLAYTAGS_API void NotifyTagReplicated(FGameplayTag Tag, bool WasInContainer); + + TMap ReplicationCountMap; + TMap ReplicationCountMap_SingleTags; + TMap ReplicationCountMap_Containers; +#endif + +private: + + /** Initializes the manager */ + static GAMEPLAYTAGS_API void InitializeManager(); + + /** finished loading/adding native tags */ + static GAMEPLAYTAGS_API FSimpleMulticastDelegate& OnDoneAddingNativeTagsDelegate(); + + /** The Tag Manager singleton */ + static GAMEPLAYTAGS_API UGameplayTagsManager* SingletonManager; + + friend class FGameplayTagTest; + friend class FGameplayEffectsTest; + friend class FGameplayTagsModule; + friend class FGameplayTagsEditorModule; + friend class UGameplayTagsSettings; + friend class SAddNewGameplayTagSourceWidget; + friend class FNativeGameplayTag; + + /** + * Helper function to get the stored TagContainer containing only this tag, which has searchable ParentTags + * NOTE: This function is not threadsafe and should only be used in code that locks the tag map critical section + * @param GameplayTag Tag to get single container of + * @return Pointer to container with this tag + */ + inline const FGameplayTagContainer* GetSingleTagContainerPtr(const FGameplayTag& GameplayTag) const + { + // Doing this with pointers to avoid a shared ptr reference count change + const TSharedPtr* Node = GameplayTagNodeMap.Find(GameplayTag); + + if (Node) + { + return &(*Node)->GetSingleTagContainer(); + } +#if WITH_EDITOR + // Check redirector + if (GIsEditor && GameplayTag.IsValid()) + { + FGameplayTag RedirectedTag = GameplayTag; + + RedirectSingleGameplayTag(RedirectedTag, nullptr); + + Node = GameplayTagNodeMap.Find(RedirectedTag); + + if (Node) + { + return &(*Node)->GetSingleTagContainer(); + } + } +#endif + return nullptr; + } + + + /** + * Helper function to insert a tag into a tag node array + * + * @param Tag Short name of tag to insert + * @param FullTag Full tag, passed in for performance + * @param ParentNode Parent node, if any, for the tag + * @param NodeArray Node array to insert the new node into, if necessary (if the tag already exists, no insertion will occur) + * @param SourceName File tag was added from + * @param DevComment Comment from developer about this tag + * @param bIsExplicitTag Is the tag explicitly defined or is it implied by the existence of a child tag + * @param bIsRestrictedTag Is the tag a restricted tag or a regular gameplay tag + * @param bAllowNonRestrictedChildren If the tag is a restricted tag, can it have regular gameplay tag children or should all of its children be restricted tags as well? + * + * @return Index of the node of the tag + */ + GAMEPLAYTAGS_API int32 InsertTagIntoNodeArray(FName Tag, FName FullTag, TSharedPtr ParentNode, TArray< TSharedPtr >& NodeArray, FName SourceName, const FString& DevComment, bool bIsExplicitTag, bool bIsRestrictedTag, bool bAllowNonRestrictedChildren); + + /** Helper function to populate the tag tree from each table */ + GAMEPLAYTAGS_API void PopulateTreeFromDataTable(class UDataTable* Table); + + GAMEPLAYTAGS_API void AddTagTableRow(const FGameplayTagTableRow& TagRow, FName SourceName, bool bIsRestrictedTag = false); + + GAMEPLAYTAGS_API void AddChildrenTags(FGameplayTagContainer& TagContainer, TSharedPtr GameplayTagNode, bool RecurseAll=true, bool OnlyIncludeDictionaryTags=false) const; + + GAMEPLAYTAGS_API void AddRestrictedGameplayTagSource(const FString& FileName); + + GAMEPLAYTAGS_API void AddTagsFromAdditionalLooseIniFiles(const TArray& IniFileList); + + /** + * Helper function for GameplayTagsMatch to get all parents when doing a parent match, + * NOTE: Must never be made public as it uses the FNames which should never be exposed + * + * @param NameList The list we are adding all parent complete names too + * @param GameplayTag The current Tag we are adding to the list + */ + GAMEPLAYTAGS_API void GetAllParentNodeNames(TSet& NamesList, TSharedPtr GameplayTag) const; + + /** Returns the tag source for a given tag source name, or null if not found */ + GAMEPLAYTAGS_API FGameplayTagSource* FindOrAddTagSource(FName TagSourceName, EGameplayTagSourceType SourceType, const FString& RootDirToUse = FString()); + + /** Constructs the net indices for each tag */ + GAMEPLAYTAGS_API void ConstructNetIndex(); + + /** Marks all of the nodes that descend from CurNode as having an ancestor node that has a source conflict. */ + GAMEPLAYTAGS_API void MarkChildrenOfNodeConflict(TSharedPtr CurNode); + + void VerifyNetworkIndex() const + { + if (!bUseFastReplication) + { + UE_LOG(LogGameplayTags, Warning, TEXT("%hs called when not using FastReplication (not rebuilding the fast replication cache)"), __func__); + } + else if (bNetworkIndexInvalidated) + { + const_cast(this)->ConstructNetIndex(); + } + } + + void InvalidateNetworkIndex() { bNetworkIndexInvalidated = true; } + + /** Called in both editor and game when the tag tree changes during startup or editing */ + GAMEPLAYTAGS_API void BroadcastOnGameplayTagTreeChanged(); + + /** Call after modifying the tag tree nodes, this will either call the full editor refresh or a limited game refresh */ + GAMEPLAYTAGS_API void HandleGameplayTagTreeChanged(bool bRecreateTree); + +#if WITH_EDITOR + void UpdateIncrementalCookHash(UE::Cook::ICookInfo& CookInfo); +#endif + + // Tag Sources + /////////////////////////////////////////////////////// + + /** These are the old native tags that use to be resisted via a function call with no specific site/ownership. */ + TSet LegacyNativeTags; + + /** Map of all config directories to load tag inis from */ + TMap RegisteredSearchPaths; + + /** Roots of gameplay tag nodes */ + TSharedPtr GameplayRootTag; + + /** Map of Tags to Nodes - Internal use only. FGameplayTag is inside node structure, do not use FindKey! */ + TMap> GameplayTagNodeMap; + + /** Our aggregated, sorted list of commonly replicated tags. These tags are given lower indices to ensure they replicate in the first bit segment. */ + TArray CommonlyReplicatedTags; + + /** Map of gameplay tag source names to source objects */ + UPROPERTY() + TMap TagSources; + + TSet RestrictedGameplayTagSourceNames; + + bool bIsConstructingGameplayTagTree = false; + + /** Cached runtime value for whether we are using fast replication or not. Initialized from config setting. */ + bool bUseFastReplication; + + /** Cached runtime value for whether we are using dynamic replication or not. Initialized from the config setting. */ + bool bUseDynamicReplication; + + /** Cached runtime value for whether we should warn when loading invalid tags */ + bool bShouldWarnOnInvalidTags; + + /** Cached runtime value for whether we should allow unloading of tags */ + bool bShouldAllowUnloadingTags; + + /** Augments usage of bShouldAllowUnloadingTags to allow runtime overrides to allow/disallow unloading of GameplayTags in controlled scenarios */ + TOptional ShouldAllowUnloadingTagsOverride; + + /** Used to suppress calls to HandleGameplayTagTreeChanged that would result in a complete rebuild of the GameplayTag tree*/ + TOptional ShouldDeferGameplayTagTreeRebuilds; + + /** True if native tags have all been added and flushed */ + bool bDoneAddingNativeTags; + + int32 bDeferBroadcastOnGameplayTagTreeChanged = 0; + bool bShouldBroadcastDeferredOnGameplayTagTreeChanged = false; + + /** If true, an action that would require a tree rebuild was performed during initialization **/ + bool bNeedsTreeRebuildOnDoneAddingGameplayTags = false; + + /** String with outlawed characters inside tags */ + FString InvalidTagCharacters; + + // This critical section is to handle an issue where tag requests come from another thread when async loading from a background thread in FGameplayTagContainer::Serialize. + // This class is not generically threadsafe, but this should be locked by any operation that could update something read by a background thread. + mutable FTransactionallySafeCriticalSection GameplayTagMapCritical; + +#if WITH_EDITOR + // Transient editor-only tags to support quick-iteration PIE workflows + TSet TransientEditorTags; + + TSet EditorRefreshGameplayTagTreeSuspendTokens; + bool bEditorRefreshGameplayTagTreeRequestedDuringSuspend = false; + + FBlake3Hash IncrementalCookHash; +#endif //if WITH_EDITOR + + /** Sorted list of nodes, used for network replication */ + TArray> NetworkGameplayTagNodeIndex; + + uint32 NetworkGameplayTagNodeIndexHash; + + bool bNetworkIndexInvalidated = true; + + /** Holds all of the valid gameplay-related tags that can be applied to assets */ + UPROPERTY() + TArray> GameplayTagTables; + + GAMEPLAYTAGS_API const static FName NAME_Categories; + GAMEPLAYTAGS_API const static FName NAME_GameplayTagFilter; + + friend class UGameplayTagsManagerIncrementalCookFunctions; +}; diff --git a/samples/GeomUtils.h b/samples/GeomUtils.h new file mode 100644 index 0000000..e661ad5 --- /dev/null +++ b/samples/GeomUtils.h @@ -0,0 +1,175 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "Containers/ContainersFwd.h" +#include "Math/UnrealMathSSE.h" +#include "Math/Vector.h" +#include "Math/Vector2D.h" + +namespace UE::AI +{ + /** @return 2D cross product. Using X and Y components of 3D vectors. */ + inline FVector::FReal Cross2D(const FVector& A, const FVector& B) + { + return A.X * B.Y - A.Y * B.X; + } + + /** @return 2D cross product. */ + inline FVector2D::FReal Cross2D(const FVector2D& A, const FVector2D& B) + { + return A.X * B.Y - A.Y * B.X; + } + + /** @return 2D area of triangle. Using X and Y components of 3D vectors. */ + inline FVector::FReal TriArea2D(const FVector& A, const FVector& B, const FVector& C) + { + const FVector AB = B - A; + const FVector AC = C - A; + return (AC.X * AB.Y - AB.X * AC.Y) * 0.5; + } + + /** @return 2D area of triangle. */ + inline FVector2D::FReal TriArea2D(const FVector2D& A, const FVector2D& B, const FVector2D& C) + { + const FVector2D AB = B - A; + const FVector2D AC = C - A; + return (AC.X * AB.Y - AB.X * AC.Y) * 0.5; + } + + /** @return value in range [0..1] of the 'Point' project on segment 'Start-End'. Using X and Y components of 3D vectors. */ + inline FVector2D::FReal ProjectPointOnSegment2D(const FVector Point, const FVector Start, const FVector End) + { + using FReal = FVector::FReal; + + const FVector2D Seg(End - Start); + const FVector2D Dir(Point - Start); + const FReal D = Seg.SquaredLength(); + const FReal T = FVector2D::DotProduct(Seg, Dir); + + if (T < 0.0) + { + return 0.0; + } + else if (T > D) + { + return 1.0; + } + + return D > UE_KINDA_SMALL_NUMBER ? (T / D) : 0.0; + } + + /** @return value of the 'Point' project on infinite line defined by segment 'Start-End'. Using X and Y components of 3D vectors. */ + inline FVector::FReal ProjectPointOnLine2D(const FVector Point, const FVector Start, const FVector End) + { + using FReal = FVector::FReal; + + const FVector2D Seg(End - Start); + const FVector2D Dir(Point - Start); + const FReal D = Seg.SquaredLength(); + const FReal T = FVector2D::DotProduct(Seg, Dir); + return D > UE_KINDA_SMALL_NUMBER ? (T / D) : 0.0; + } + + /** @return signed distance of the 'Point' to infinite line defined by segment 'Start-End'. Using X and Y components of 3D vectors. */ + inline FVector::FReal SignedDistancePointLine2D(const FVector Point, const FVector Start, const FVector End) + { + using FReal = FVector::FReal; + + const FVector2D Seg(End - Start); + const FVector2D Dir(Point - Start); + const FReal Nom = Cross2D(Seg, Dir); + const FReal Den = Seg.SquaredLength(); + const FReal Dist = Den > UE_KINDA_SMALL_NUMBER ? (Nom / FMath::Sqrt(Den)) : 0.0; + return Dist; + } + + /** + * Intersects infinite lines defined by segments A and B in 2D. Using X and Y components of 3D vectors. + * @param StartA start point of segment A + * @param EndA end point of segment A + * @param StartB start point of segment B + * @param EndB end point of segment B + * @param OutTA intersection value along segment A + * @param OutTB intersection value along segment B + * @return if segments A and B intersect in 2D + */ + inline bool IntersectLineLine2D(const FVector& StartA, const FVector& EndA, const FVector& StartB, const FVector& EndB, FVector2D::FReal& OutTA, FVector2D::FReal& OutTB) + { + using FReal = FVector::FReal; + + const FVector U = EndA - StartA; + const FVector V = EndB - StartB; + const FVector W = StartA - StartB; + + const FReal D = Cross2D(U, V); + if (FMath::Abs(D) < UE_KINDA_SMALL_NUMBER) + { + OutTA = 0.0; + OutTB = 0.0; + return false; + } + + OutTA = Cross2D(V, W) / D; + OutTB = Cross2D(U, W) / D; + + return true; + } + + /** + * Calculates intersection of segment Start-End with convex polygon Poly in 2D. Using X and Y components of 3D vectors. + * @param Start start point of the segment + * @param End end point of the segment + * @param Poly convex polygon + * @param OutTMin value along the segment of the first intersection point [0..1] + * @param OutTMax value along the segment of the second intersection point [0..1] + * @param OutSegMin index of the polygon segment of the first intersection point + * @param OutSegMax index of the polygon segment of the second intersection point + * @return true if the segment inside or intersects with the polygon. + */ + extern AIMODULE_API bool IntersectSegmentPoly2D(const FVector& Start, const FVector& End, TConstArrayView Poly, + FVector2D::FReal& OutTMin, FVector2D::FReal& OutTMax, int32& OutSegMin, int32& OutSegMax); + + /** + * Interpolates bilinear patch A,B,C,D. U interpolates from A->B, and C->D, and V interpolates from AB->CD. + * @param UV interpolation coordinates [0..1] range + * @param VertexA first corner + * @param VertexB second corner + * @param VertexC third corner + * @param VertexD fourth corner + * @return interpolated value. + */ + inline FVector Bilinear(const FVector2D UV, const FVector VertexA, const FVector VertexB, const FVector VertexC, const FVector VertexD) + { + const FVector AB = FMath::Lerp(VertexA, VertexB, UV.X); + const FVector CD = FMath::Lerp(VertexD, VertexC, UV.X); + return FMath::Lerp(AB, CD, UV.Y); + } + + /** + * Finds the UV coordinates of the 'Point' on bilinear patch A,B,C,D. U interpolates from A->B, and C->D, and V interpolates from AB->CD. + * @param Point location inside or close to the bilinear patch + * @param VertexA first corner + * @param VertexB second corner + * @param VertexC third corner + * @param VertexD fourth corner + * @return UV interpolation coordinates of the 'Point'. + */ + extern AIMODULE_API FVector2D InvBilinear2D(const FVector Point, const FVector VertexA, const FVector VertexB, const FVector VertexC, const FVector VertexD); + + /** + * Finds the UV coordinates of the 'Point' on bilinear patch A,B,C,D. U interpolates from A->B, and C->D, and V interpolates from AB->CD. + * The UV coordinate is clamped to [0..1] range after inversion. + * @param Point location inside or close to the bilinear patch + * @param VertexA first corner + * @param VertexB second corner + * @param VertexC third corner + * @param VertexD fourth corner + * @return UV interpolation coordinates of the 'Point' in [0..1] range. + */ + inline FVector2D InvBilinear2DClamped(const FVector Point, const FVector VertexA, const FVector VertexB, const FVector VertexC, const FVector VertexD) + { + return InvBilinear2D(Point, VertexA, VertexB, VertexC, VertexD).ClampAxes(0.0, 1.0); + } + +}; // UE::AI \ No newline at end of file diff --git a/ue_markdown.py b/ue_markdown.py new file mode 100644 index 0000000..62bc06b --- /dev/null +++ b/ue_markdown.py @@ -0,0 +1,405 @@ +""" +ue_markdown.py — Render a ParsedHeader into compact, agent-readable Markdown. + +Format goals: +- Ultra-compact function entries: signature in heading, params folded into prose +- Bullet-list properties (no tables) +- Only items with actual C++ doc comments (or deprecated annotations) are emitted +- Cross-reference links to other files in the corpus (via type_index) +""" +from __future__ import annotations +import os +import re +from typing import Optional +from ue_parser import ( + ParsedHeader, ClassInfo, EnumInfo, DelegateInfo, NamespaceInfo, + FreeFunction, FunctionInfo, PropertyInfo, DocComment, _split_params, +) + + +# --------------------------------------------------------------------------- +# Cross-reference utilities +# --------------------------------------------------------------------------- + +def _rel_link(current_md: str, target_md: str) -> str: + """ + Compute a relative markdown link from the directory of current_md to target_md. + Both paths are relative to the docs root. + """ + cur_dir = os.path.dirname(current_md) or '.' + return os.path.relpath(target_md, cur_dir).replace('\\', '/') + + +def _make_type_link(name: str, type_index: dict[str, str], + current_md: str) -> str: + """Return '[Name](relative/path.md)' if in corpus, else '`Name`'.""" + if name in type_index: + link = _rel_link(current_md, type_index[name]) + return f"[{name}]({link})" + return f"`{name}`" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _has_doc(comment: Optional[DocComment]) -> bool: + if comment is None: + return False + return bool(comment.description or comment.params or comment.returns) + + +def _uf_category(uf_specs: str) -> str: + m = re.search(r'Category\s*=\s*"([^"]*)"', uf_specs) + if m: + return m.group(1) + m = re.search(r'Category\s*=\s*([\w|]+)', uf_specs) + return m.group(1) if m else "" + + +def _uf_flags(uf_specs: str) -> list: + flags = [] + for flag in ('BlueprintCallable', 'BlueprintPure', 'BlueprintImplementableEvent', + 'BlueprintNativeEvent', 'Exec', 'NetMulticast', 'Server', 'Client', + 'CallInEditor'): + if re.search(r'\b' + flag + r'\b', uf_specs): + flags.append(flag) + return flags + + +def _up_flags(specs: str) -> list: + notable = [ + 'EditAnywhere', 'EditDefaultsOnly', 'EditInstanceOnly', + 'VisibleAnywhere', 'VisibleDefaultsOnly', + 'BlueprintReadWrite', 'BlueprintReadOnly', 'BlueprintAssignable', + 'Replicated', 'ReplicatedUsing', 'Transient', 'SaveGame', 'Config', + ] + return [f for f in notable if re.search(r'\b' + f + r'\b', specs)] + + +def _compact_params(raw_params: str, max_count: int = 3) -> str: + params = _split_params(raw_params) + if not params: + return "" + shown = [] + for p in params[:max_count]: + p = p.strip() + p = re.sub(r'\s*=\s*', '=', p) + shown.append(p) + result = ', '.join(shown) + if len(params) > max_count: + result += ', ...' + return result + + +def _fn_flags_str(fn: FunctionInfo) -> str: + parts = [] + if fn.is_deprecated: + ver = fn.deprecated_version or "?" + parts.append(f"Deprecated {ver}") + if fn.uf_specifiers: + uf = _uf_flags(fn.uf_specifiers) + cat = _uf_category(fn.uf_specifiers) + annotation = ', '.join(uf) + if cat: + annotation = (annotation + ' — ' if annotation else '') + cat + if annotation: + parts.append(annotation) + for mod in fn.modifiers: + if mod in ('virtual', 'static', 'inline', 'constexpr'): + parts.append(mod) + if fn.editor_only: + parts.append("Editor only") + return f" *({', '.join(parts)})*" if parts else "" + + +_PLACEHOLDER_RE = re.compile(r'^[-_/\\]{6,}') + + +def _fn_body(fn: FunctionInfo) -> str: + parts = [] + c = fn.comment + if c: + if c.description and not _PLACEHOLDER_RE.match(c.description): + desc = c.description.rstrip('.') + parts.append(desc + '.') + for pname, pdesc in c.params.items(): + pdesc = pdesc.rstrip('.') + parts.append(f"`{pname}`: {pdesc}.") + if c.returns: + ret = c.returns.rstrip('.') + parts.append(f"**Returns** {ret}.") + return ' '.join(parts) + + +def _render_function_compact(fn: FunctionInfo) -> str: + params_str = _compact_params(fn.raw_params) + heading = f"##### `{fn.name}({params_str})`" + if fn.return_type: + heading += f" → `{fn.return_type}`" + heading += _fn_flags_str(fn) + body = _fn_body(fn) + return heading + ('\n' + body if body else '') + + +def _render_ff_compact(fn: FreeFunction, overloads: list = None) -> str: + overloads = overloads or [fn] + fn0 = overloads[0] + params_str = _compact_params(fn0.raw_params) + heading = f"##### `{fn0.name}({params_str})`" + if fn0.return_type: + heading += f" → `{fn0.return_type}`" + flag_parts = [m for m in fn0.modifiers if m in ('inline', 'static', 'constexpr')] + if len(overloads) > 1: + flag_parts.append(f"{len(overloads)} overloads") + if flag_parts: + heading += f" *({', '.join(flag_parts)})*" + body = "" + for f in overloads: + b = _fn_body(f) + if b: + body = b + break + return heading + ('\n' + body if body else '') + + +# --------------------------------------------------------------------------- +# Section renderers +# --------------------------------------------------------------------------- + +def _render_delegates(delegates: list) -> str: + if not delegates: + return "" + lines = ["## Delegates"] + for d in delegates: + suffix_parts = [] + if d.is_dynamic: + suffix_parts.append("Dynamic") + if d.is_multicast: + suffix_parts.append("Multicast") + suffix = ", ".join(suffix_parts) + head = f"### `{d.name}`" + if suffix: + head += f" *({suffix})*" + lines.append(head) + if d.comment and d.comment.description: + lines.append(d.comment.description) + if d.params: + param_str = ", ".join(f"`{t} {n}`" for t, n in d.params) + lines.append(f"Params: {param_str}") + return '\n'.join(lines) + + +def _render_enum(ei: EnumInfo) -> str: + head = f"### `{ei.name}`" + if ei.editor_only: + head += " *(Editor only)*" + lines = [head] + if ei.comment and ei.comment.description: + lines.append(ei.comment.description) + if ei.values: + has_descriptions = any(v.comment for v in ei.values) + if has_descriptions: + lines.append("| Value | Description |") + lines.append("|-------|-------------|") + for v in ei.values: + val_str = f"`{v.name}`" + if v.value: + val_str += f" (={v.value})" + lines.append(f"| {val_str} | {v.comment} |") + else: + # Compact inline list when no value has a description + vals = [v.name for v in ei.values] + lines.append("Values: " + ", ".join(f"`{v}`" for v in vals)) + return '\n'.join(lines) + + +def _render_enums(enums: list, heading: str = "## Enums") -> str: + if not enums: + return "" + parts = [heading] + for ei in enums: + parts.append(_render_enum(ei)) + return '\n'.join(parts) + + +def _render_properties(props: list) -> str: + visible = [p for p in props + if p.access in ('public', 'protected') and _has_doc(p.comment)] + if not visible: + return "" + lines = ["#### Properties"] + for p in visible: + flags = ', '.join(_up_flags(p.specifiers)) + desc = p.comment.description if p.comment else "" + line = f"- `{p.name}` `{p.type}`" + if flags: + line += f" *({flags})*" + if desc: + line += f" — {desc}" + if p.editor_only: + line += " *(Editor only)*" + lines.append(line) + return '\n'.join(lines) + + +def _render_functions(fns: list) -> str: + visible = [f for f in fns + if f.access in ('public', 'protected') + and _has_doc(f.comment) + and not f.is_deprecated] + if not visible: + return "" + + lines = ["#### Functions"] + seen: dict[str, list] = {} + ordered: list[str] = [] + for fn in visible: + if fn.name not in seen: + seen[fn.name] = [] + ordered.append(fn.name) + seen[fn.name].append(fn) + + for name in ordered: + group = seen[name] + if len(group) == 1: + lines.append(_render_function_compact(group[0])) + else: + fn0 = group[0] + params_str = _compact_params(fn0.raw_params) + heading = f"##### `{fn0.name}({params_str})`" + if fn0.return_type: + heading += f" → `{fn0.return_type}`" + flag_parts = [] + if fn0.is_deprecated: + flag_parts.append(f"Deprecated {fn0.deprecated_version or '?'}") + if fn0.uf_specifiers: + uf = _uf_flags(fn0.uf_specifiers) + cat = _uf_category(fn0.uf_specifiers) + ann = ', '.join(uf) + if cat: + ann = (ann + ' — ' if ann else '') + cat + if ann: + flag_parts.append(ann) + flag_parts.append(f"{len(group)} overloads") + heading += f" *({', '.join(flag_parts)})*" + lines.append(heading) + for fn in group: + body = _fn_body(fn) + if body: + lines.append(body) + break + + return '\n'.join(lines) + + +def _class_has_content(ci: ClassInfo) -> bool: + if _has_doc(ci.comment): + return True + if any(p.access in ('public', 'protected') and _has_doc(p.comment) + for p in ci.properties): + return True + if any(f.access in ('public', 'protected') and (_has_doc(f.comment) or f.is_deprecated) + for f in ci.functions): + return True + if ci.nested_enums: + return True + return False + + +def _render_class(ci: ClassInfo, + type_index: dict[str, str], current_md: str) -> str: + lines = [] + head = f"### `{ci.name}` *({ci.kind})*" + lines.append(head) + + if ci.bases: + linked = [_make_type_link(b, type_index, current_md) for b in ci.bases] + lines.append("*Inherits*: " + ", ".join(linked)) + + if ci.module_api: + lines.append(f"*API*: `{ci.module_api}`") + if ci.comment and ci.comment.description: + lines.append(ci.comment.description) + + if ci.nested_enums: + lines.append(_render_enums(ci.nested_enums, "#### Enums")) + + props_section = _render_properties(ci.properties) + if props_section: + lines.append(props_section) + + fns_section = _render_functions(ci.functions) + if fns_section: + lines.append(fns_section) + + return '\n'.join(lines) + + +def _render_namespace(ns: NamespaceInfo) -> str: + doc_fns = [f for f in ns.functions if _has_doc(f.comment)] + if not doc_fns: + return "" + lines = [f"## Free Functions — `{ns.name}`"] + seen: dict[str, list] = {} + ordered: list[str] = [] + for fn in doc_fns: + if fn.name not in seen: + seen[fn.name] = [] + ordered.append(fn.name) + seen[fn.name].append(fn) + for name in ordered: + group = seen[name] + lines.append(_render_ff_compact(group[0], group)) + return '\n'.join(lines) + + +# --------------------------------------------------------------------------- +# Main render function +# --------------------------------------------------------------------------- + +def render_header(parsed: ParsedHeader, + type_index: dict[str, str] = None, + current_md: str = "") -> str: + if type_index is None: + type_index = {} + + lines = [] + lines.append(f"# `{parsed.filename}`") + lines.append(f"**Module**: `{parsed.module_name}`") + lines.append("") + + sections = [] + + d_sec = _render_delegates(parsed.delegates) + if d_sec.strip(): + sections.append(d_sec) + + e_sec = _render_enums(parsed.enums) + if e_sec.strip(): + sections.append(e_sec) + + if parsed.classes: + doc_classes = [ci for ci in parsed.classes if _class_has_content(ci)] + if doc_classes: + cls_lines = ["## Classes"] + for ci in doc_classes: + cls_lines.append(_render_class(ci, type_index, current_md)) + sections.append('\n'.join(cls_lines)) + + for ns in parsed.namespaces: + ns_sec = _render_namespace(ns) + if ns_sec.strip(): + sections.append(ns_sec) + + if parsed.free_functions: + doc_fns = [f for f in parsed.free_functions if _has_doc(f.comment)] + if doc_fns: + ff_lines = ["## Free Functions"] + for fn in doc_fns: + ff_lines.append(_render_ff_compact(fn)) + sections.append('\n'.join(ff_lines)) + + lines.append('\n\n---\n\n'.join(sections)) + + return '\n'.join(lines) diff --git a/ue_parser.py b/ue_parser.py new file mode 100644 index 0000000..cab7910 --- /dev/null +++ b/ue_parser.py @@ -0,0 +1,1183 @@ +""" +ue_parser.py — Parse Unreal Engine C++ header files into Python dataclasses. +""" +from __future__ import annotations +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class DocComment: + description: str = "" + params: dict = field(default_factory=dict) # name -> desc + returns: str = "" + + +@dataclass +class PropertyInfo: + name: str = "" + type: str = "" + specifiers: str = "" # raw UPROPERTY(...) content + access: str = "public" + editor_only: bool = False + comment: Optional[DocComment] = None + + +@dataclass +class FunctionInfo: + name: str = "" + return_type: str = "" + raw_params: str = "" + full_signature: str = "" + uf_specifiers: str = "" # raw UFUNCTION(...) content + comment: Optional[DocComment] = None + modifiers: list = field(default_factory=list) + access: str = "public" + is_deprecated: bool = False + deprecated_version: str = "" + deprecated_msg: str = "" + editor_only: bool = False + + +@dataclass +class EnumValue: + name: str = "" + value: str = "" + comment: str = "" + + +@dataclass +class EnumInfo: + name: str = "" + underlying_type: str = "" + ue_specifiers: str = "" + comment: Optional[DocComment] = None + values: list = field(default_factory=list) + editor_only: bool = False + + +@dataclass +class ClassInfo: + name: str = "" + kind: str = "class" + bases: list = field(default_factory=list) + ue_specifiers: str = "" + module_api: str = "" + comment: Optional[DocComment] = None + properties: list = field(default_factory=list) + functions: list = field(default_factory=list) + nested_enums: list = field(default_factory=list) + + +@dataclass +class DelegateInfo: + name: str = "" + macro: str = "" + params: list = field(default_factory=list) # list[(type, name)] + comment: Optional[DocComment] = None + is_multicast: bool = False + is_dynamic: bool = False + + +@dataclass +class FreeFunction: + name: str = "" + full_signature: str = "" + return_type: str = "" + raw_params: str = "" + comment: Optional[DocComment] = None + modifiers: list = field(default_factory=list) + + +@dataclass +class NamespaceInfo: + name: str = "" + functions: list = field(default_factory=list) + + +@dataclass +class ParsedHeader: + filepath: str = "" + filename: str = "" + module_name: str = "" + classes: list = field(default_factory=list) + enums: list = field(default_factory=list) + delegates: list = field(default_factory=list) + namespaces: list = field(default_factory=list) + free_functions: list = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Core utilities +# --------------------------------------------------------------------------- + +def find_matching_close(text: str, open_pos: int, open_ch: str, close_ch: str) -> int: + """ + Given text[open_pos] == open_ch, return index of matching close_ch. + Skips // comments, /* */ comments, and string literals. + Returns -1 if not found. + """ + depth = 0 + i = open_pos + n = len(text) + while i < n: + ch = text[i] + # Line comment + if ch == '/' and i + 1 < n and text[i + 1] == '/': + while i < n and text[i] != '\n': + i += 1 + continue + # Block comment + if ch == '/' and i + 1 < n and text[i + 1] == '*': + i += 2 + while i < n - 1 and not (text[i] == '*' and text[i + 1] == '/'): + i += 1 + i += 2 + continue + # String literal + if ch == '"': + i += 1 + while i < n: + if text[i] == '\\': + i += 2 + continue + if text[i] == '"': + break + i += 1 + i += 1 + continue + if ch == open_ch: + depth += 1 + elif ch == close_ch: + depth -= 1 + if depth == 0: + return i + i += 1 + return -1 + + +def extract_balanced(text: str, start: int, open_ch: str, close_ch: str): + """ + Find open_ch at or after start, return (inner_content, end_pos). + end_pos is one past the closing char. Returns ("", start) if not found. + """ + op = text.find(open_ch, start) + if op == -1: + return ("", start) + cl = find_matching_close(text, op, open_ch, close_ch) + if cl == -1: + return ("", start) + return (text[op + 1:cl], cl + 1) + + +def _split_params(raw_params: str) -> list: + """Split param string respecting <> and () nesting.""" + params = [] + depth = 0 + current = [] + for ch in raw_params: + if ch in '<({': + depth += 1 + current.append(ch) + elif ch in '>)}': + depth -= 1 + current.append(ch) + elif ch == ',' and depth == 0: + p = ''.join(current).strip() + if p: + params.append(p) + current = [] + else: + current.append(ch) + p = ''.join(current).strip() + if p: + params.append(p) + return params + + +# --------------------------------------------------------------------------- +# Doc comment parsing +# --------------------------------------------------------------------------- + +def parse_doc_comment(raw: str) -> DocComment: + """Parse a /** ... */ or // ... doc comment block.""" + text = raw.strip() + if text.startswith('/**'): + text = text[3:] + if text.endswith('*/'): + text = text[:-2] + + lines = [] + for line in text.split('\n'): + line = line.strip().lstrip('*').strip() + lines.append(line) + + doc = DocComment() + desc_lines = [] + i = 0 + while i < len(lines): + line = lines[i] + # @param + m = re.match(r'@param(?:\[[\w,]+\])?\s+(\w+)\s*(.*)', line, re.IGNORECASE) + if m: + pname, pdesc = m.group(1), m.group(2).strip() + i += 1 + while i < len(lines) and lines[i] and not lines[i].startswith('@'): + pdesc += ' ' + lines[i] + i += 1 + doc.params[pname] = pdesc.strip(' -') + continue + # @return / @returns + m = re.match(r'@returns?\s*(.*)', line, re.IGNORECASE) + if m: + rdesc = m.group(1).strip() + i += 1 + while i < len(lines) and lines[i] and not lines[i].startswith('@'): + rdesc += ' ' + lines[i] + i += 1 + doc.returns = rdesc.strip(' -') + continue + # @note, @see etc + m = re.match(r'@(?:note|see|todo)\s*(.*)', line, re.IGNORECASE) + if m: + note = m.group(1).strip() + if note: + desc_lines.append(note) + i += 1 + continue + # Skip other @tags + if re.match(r'@\w+', line): + i += 1 + continue + desc_lines.append(line) + i += 1 + + doc.description = ' '.join(l for l in desc_lines if l).strip() + return doc + + +# --------------------------------------------------------------------------- +# Preprocessor tracking +# --------------------------------------------------------------------------- + +class EditorOnlyTracker: + EDITOR_GUARDS = {'WITH_EDITOR', 'WITH_EDITORONLY_DATA'} + + def __init__(self): + self._stack: list[bool] = [] + + def handle_line(self, line: str): + s = line.strip() + if s.startswith('#if ') or s.startswith('#ifdef '): + cond = s.split(None, 1)[1].strip() if ' ' in s else '' + is_editor = any(g in cond for g in self.EDITOR_GUARDS) + self._stack.append(is_editor) + elif s.startswith('#elif') or s.startswith('#else'): + if self._stack: + self._stack[-1] = False + elif s.startswith('#endif'): + if self._stack: + self._stack.pop() + + @property + def editor_only(self) -> bool: + return any(self._stack) + + +# --------------------------------------------------------------------------- +# Function signature parsing +# --------------------------------------------------------------------------- + +_API_RE = re.compile(r'\b[A-Z][A-Z0-9_]+_API\b\s*') +_MUTABLE_RE = re.compile(r'\bmutable\b\s*') + +_PREFIX_MODS = {'virtual', 'static', 'inline', 'explicit', 'friend', + 'forceinline', 'forcenoinline', 'ue_nodiscard', 'constexpr', + 'extern'} + + +def _clean_type(t: str) -> str: + return ' '.join(t.split()) + + +def _parse_function_signature(sig: str): + """ + Parse: [modifiers] [return_type] name(params) + Returns (return_type, name, raw_params, modifiers). + """ + sig = _API_RE.sub('', sig).strip() + + # Find the params: rightmost balanced () pair + paren_close = len(sig) - 1 + while paren_close >= 0 and sig[paren_close] != ')': + paren_close -= 1 + if paren_close < 0: + return "", sig.strip(), "", [] + + # Walk backward from paren_close to find matching ( + depth = 0 + paren_open = paren_close + while paren_open >= 0: + if sig[paren_open] == ')': + depth += 1 + elif sig[paren_open] == '(': + depth -= 1 + if depth == 0: + break + paren_open -= 1 + + if paren_open < 0: + return "", sig.strip(), "", [] + + raw_params = sig[paren_open + 1:paren_close].strip() + before = sig[:paren_open].strip() + + # Extract leading modifier keywords + modifiers = [] + words = before.split() + i = 0 + while i < len(words): + w = words[i].lower() + if w in _PREFIX_MODS: + modifiers.append(words[i].lower()) + i += 1 + else: + break + before = ' '.join(words[i:]) + + # Last token is function name + tokens = before.rsplit(None, 1) + if len(tokens) == 2: + return_type = _clean_type(tokens[0]) + name = tokens[1] + elif len(tokens) == 1: + return_type = "" + name = tokens[0] + else: + return_type = "" + name = "" + + # Handle pointer/ref in name vs return_type + while name.startswith(('*', '&')): + return_type = return_type + name[0] + name = name[1:] + + return _clean_type(return_type), name.strip(), raw_params, modifiers + + +# --------------------------------------------------------------------------- +# Enum body parsing +# --------------------------------------------------------------------------- + +def _parse_enum_body(body: str) -> list: + values = [] + for line in body.split('\n'): + comment = "" + ci = line.find('//') + if ci != -1: + comment = line[ci + 2:].strip() + line = line[:ci] + line = line.strip().rstrip(',').strip() + if not line: + continue + # Skip UMETA(...) + line = re.sub(r'UMETA\s*\([^)]*\)', '', line).strip() + if not line: + continue + m = re.match(r'(\w+)\s*=\s*(.*)', line) + if m: + values.append(EnumValue(name=m.group(1).strip(), + value=m.group(2).strip(), comment=comment)) + elif re.match(r'\w+\s*$', line): + values.append(EnumValue(name=line.strip(), value="", comment=comment)) + return values + + +# --------------------------------------------------------------------------- +# Delegate parsing +# --------------------------------------------------------------------------- + +def _parse_delegate(macro: str, args_str: str, + comment: Optional[DocComment]) -> DelegateInfo: + is_dynamic = 'DYNAMIC' in macro + is_multicast = 'MULTICAST' in macro + args = [a.strip() for a in args_str.split(',') if a.strip()] + name = args[0] if args else "" + rest = args[1:] + params = [] + for i in range(0, len(rest) - 1, 2): + ptype = rest[i].strip() + pname = rest[i + 1].strip() if i + 1 < len(rest) else "" + if ptype: + params.append((ptype, pname)) + return DelegateInfo(name=name, macro=macro, params=params, comment=comment, + is_multicast=is_multicast, is_dynamic=is_dynamic) + + +# --------------------------------------------------------------------------- +# Class body parsing — line-based approach to avoid regex backtracking +# --------------------------------------------------------------------------- + +# Macros to skip entirely (skip line when encountered) +_SKIP_MACROS = frozenset({ + 'GENERATED_BODY', 'GENERATED_UCLASS_BODY', 'GENERATED_USTRUCT_BODY', + 'GENERATED_USTRUCT_BODY', 'check', 'checkSlow', 'ensure', 'checkf', + 'UE_LOG', 'DECLARE_LOG_CATEGORY_EXTERN', 'DECLARE_STATS_GROUP', +}) + +# Macros we handle explicitly (don't treat as regular lines) +_HANDLED_MACROS = frozenset({ + 'UPROPERTY', 'UFUNCTION', 'UENUM', 'UE_DEPRECATED', 'UE_DEPRECATED_FORGAME', + 'DECLARE_DELEGATE', 'DECLARE_MULTICAST_DELEGATE', 'DECLARE_DYNAMIC_DELEGATE', + 'DECLARE_DYNAMIC_MULTICAST_DELEGATE', 'DECLARE_TS_MULTICAST_DELEGATE', + 'DECLARE_MULTICAST_DELEGATE_OneParam', 'DECLARE_MULTICAST_DELEGATE_TwoParams', + 'DECLARE_MULTICAST_DELEGATE_ThreeParams', +}) + +# C++ keywords that start non-function statements +_SKIP_KEYWORDS = frozenset({ + 'if', 'else', 'while', 'for', 'do', 'switch', 'return', 'break', + 'continue', 'typedef', 'using', 'namespace', 'template', + 'throw', 'try', 'catch', 'static_assert', 'static_cast', + 'reinterpret_cast', 'dynamic_cast', 'const_cast', +}) + + +def _parse_class_body(body: str, class_kind: str = 'class') -> tuple: + """ + Parse class/struct body. Returns (properties, functions, nested_enums). + Uses a position-based scanner; falls back to line-skip for unrecognized patterns. + """ + properties: list = [] + functions: list = [] + nested_enums: list = [] + + access = 'private' if class_kind == 'class' else 'public' + editor_tracker = EditorOnlyTracker() + pending_comment: Optional[DocComment] = None + pending_deprecated = False + pending_dep_version = "" + pending_dep_msg = "" + + i = 0 + n = len(body) + + def advance_line() -> int: + """Return position after the next newline from i.""" + end = body.find('\n', i) + return (end + 1) if end != -1 else n + + def skip_block(pos: int) -> int: + """Skip a {…} block starting at or after pos.""" + bp = body.find('{', pos) + if bp == -1: + return pos + cl = find_matching_close(body, bp, '{', '}') + if cl == -1: + return pos + ep = cl + 1 + while ep < n and body[ep] in ' \t\r\n': + ep += 1 + if ep < n and body[ep] == ';': + ep += 1 + return ep + + while i < n: + # Skip pure whitespace + while i < n and body[i] in ' \t\r\n': + i += 1 + if i >= n: + break + + ch = body[i] + + # --- Preprocessor --- + if ch == '#': + end = body.find('\n', i) + pp_line = body[i:(end if end != -1 else n)] + editor_tracker.handle_line(pp_line) + i = (end + 1) if end != -1 else n + continue + + # --- Doc comment /** */ --- + if body[i:i+3] == '/**': + end = body.find('*/', i) + if end == -1: + i += 3 + continue + pending_comment = parse_doc_comment(body[i:end + 2]) + i = end + 2 + continue + + # --- Block comment /* */ --- + if body[i:i+2] == '/*': + end = body.find('*/', i) + i = (end + 2) if end != -1 else n + continue + + # --- Line comment // --- + if body[i:i+2] == '//': + end = body.find('\n', i) + line_text = body[i:(end if end != -1 else n)].lstrip('/').strip() + if pending_comment is None and line_text: + pending_comment = DocComment(description=line_text) + i = (end + 1) if end != -1 else n + continue + + # --- Read identifier at current position --- + m = re.match(r'\w+', body[i:]) + if not m: + # Not an identifier; skip character + i += 1 + continue + ident = m.group(0) + + # --- Access specifier: public: protected: private: --- + if ident in ('public', 'protected', 'private'): + colon_pos = i + len(ident) + while colon_pos < n and body[colon_pos] in ' \t': + colon_pos += 1 + if colon_pos < n and body[colon_pos] == ':': + access = ident + i = colon_pos + 1 + pending_comment = None + continue + + # --- GENERATED_BODY etc --- + if ident in _SKIP_MACROS or (ident.startswith('GENERATED_') and ident.endswith('BODY')): + # Skip to end of line or semicolon + end = body.find('\n', i) + i = (end + 1) if end != -1 else n + pending_comment = None + continue + + # --- UE_DEPRECATED / UE_DEPRECATED_FORGAME --- + if ident in ('UE_DEPRECATED', 'UE_DEPRECATED_FORGAME'): + inner, ep = extract_balanced(body, i, '(', ')') + parts = inner.split(',', 1) + pending_dep_version = parts[0].strip().strip('"') + pending_dep_msg = parts[1].strip().strip('"') if len(parts) > 1 else "" + pending_deprecated = True + i = ep + continue + + # --- UPROPERTY --- + if ident == 'UPROPERTY': + inner, ep = extract_balanced(body, i, '(', ')') + specifiers = inner.strip() + comment = pending_comment + pending_comment = None + # Advance to property declaration + j = ep + while j < n and body[j] in ' \t\r\n': + j += 1 + semi = body.find(';', j) + if semi == -1: + i = ep + continue + decl = _API_RE.sub('', body[j:semi]).strip() + decl = _MUTABLE_RE.sub('', decl).strip() + # Parse: TYPE NAME [: bits] + m2 = re.match(r'(.*?)\s+(\w+)\s*(?::\s*\d+)?\s*$', decl, re.DOTALL) + if m2: + ptype = _clean_type(m2.group(1)) + pname = m2.group(2) + bf = re.search(r':\s*(\d+)', decl) + if bf: + ptype += ' : ' + bf.group(1) + else: + ptype = decl + pname = "" + prop = PropertyInfo(name=pname, type=ptype, specifiers=specifiers, + access=access, editor_only=editor_tracker.editor_only, + comment=comment) + properties.append(prop) + i = semi + 1 + continue + + # --- UFUNCTION --- + if ident == 'UFUNCTION': + inner, ep = extract_balanced(body, i, '(', ')') + uf_specs = inner.strip() + comment = pending_comment + pending_comment = None + j = ep + while j < n and body[j] in ' \t\r\n': + j += 1 + # Read function signature until ; or { + k = j + depth = 0 + while k < n: + if body[k] == '(': + depth += 1 + elif body[k] == ')': + depth -= 1 + elif body[k] == '{' and depth == 0: + break + elif body[k] == ';' and depth == 0: + break + k += 1 + sig_text = _API_RE.sub('', body[j:k]).strip() + # Skip body if inline + end_fn = k + if k < n and body[k] == '{': + cl = find_matching_close(body, k, '{', '}') + end_fn = (cl + 1) if cl != -1 else k + 1 + elif k < n and body[k] == ';': + end_fn = k + 1 + ret, name, raw_params, mods = _parse_function_signature(sig_text) + fn = FunctionInfo( + name=name, return_type=ret, raw_params=raw_params, + full_signature=sig_text.strip(), uf_specifiers=uf_specs, + comment=comment, modifiers=mods, access=access, + is_deprecated=pending_deprecated, deprecated_version=pending_dep_version, + deprecated_msg=pending_dep_msg, editor_only=editor_tracker.editor_only, + ) + functions.append(fn) + pending_deprecated = False + pending_dep_version = "" + pending_dep_msg = "" + i = end_fn + continue + + # --- UENUM --- + if ident == 'UENUM': + inner, ep = extract_balanced(body, i, '(', ')') + ue_specs = inner.strip() + comment = pending_comment + pending_comment = None + j = ep + while j < n and body[j] in ' \t\r\n': + j += 1 + result = _parse_enum_at(body, j, ue_specs, comment, editor_tracker.editor_only) + if result: + nested_enums.append(result[0]) + i = result[1] + else: + i = j + continue + + # --- DECLARE_*_DELEGATE inside class (inline delegate typedef) --- + if ident.startswith('DECLARE_') and 'DELEGATE' in ident: + # Skip to end of line + end = body.find('\n', i) + i = (end + 1) if end != -1 else n + pending_comment = None + continue + + # --- Nested struct/class --- + if ident in ('struct', 'class'): + # Find the next { - check if this is a definition (not forward decl) + bp = body.find('{', i) + semi = body.find(';', i) + if bp != -1 and (semi == -1 or bp < semi): + # Has a body - skip the whole nested class + i = skip_block(bp) + pending_comment = None + else: + # Forward declaration - skip to ; + i = (semi + 1) if semi != -1 else advance_line() + pending_comment = None + continue + + # --- Nested enum --- + if ident == 'enum': + result = _parse_enum_at(body, i, "", pending_comment, editor_tracker.editor_only) + if result: + nested_enums.append(result[0]) + pending_comment = None + i = result[1] + else: + i = advance_line() + pending_comment = None + continue + + # --- Skip C++ keywords that aren't declarations --- + if ident in _SKIP_KEYWORDS: + i = advance_line() + pending_comment = None + continue + + # --- Try to parse as a function or method --- + # Look for '(' on the current line (safe: no cross-line backtracking) + line_end = body.find('\n', i) + if line_end == -1: + line_end = n + current_line = body[i:line_end] + + paren_in_line = current_line.find('(') + if paren_in_line != -1: + abs_open = i + paren_in_line + # Make sure there's a word just before '(' + before_paren = body[i:abs_open].rstrip() + if before_paren and re.search(r'\w$', before_paren): + cl_pos = find_matching_close(body, abs_open, '(', ')') + if cl_pos != -1: + sig_text = _API_RE.sub('', body[i:cl_pos + 1]).strip() + sig_text = _MUTABLE_RE.sub('', sig_text).strip() + ret, name, raw_params, mods = _parse_function_signature(sig_text) + + # Skip obvious non-functions + skip = (not name or name in _SKIP_KEYWORDS or + name in _SKIP_MACROS or name in _HANDLED_MACROS) + # All-uppercase is probably a macro + if name and name == name.upper() and '_' in name: + skip = True + + if not skip: + # Skip inline body or find end of declaration + end_fn = cl_pos + 1 + k = end_fn + while k < n and body[k] in ' \t': + k += 1 + if k < n and body[k] == '{': + cl2 = find_matching_close(body, k, '{', '}') + end_fn = (cl2 + 1) if cl2 != -1 else k + 1 + else: + # Find ; on current or next line + semi = body.find(';', cl_pos + 1) + nl = body.find('\n', cl_pos + 1) + if semi != -1 and (nl == -1 or semi <= nl + 2): + end_fn = semi + 1 + else: + end_fn = (nl + 1) if nl != -1 else n + + comment = pending_comment + pending_comment = None + + fn = FunctionInfo( + name=name, return_type=ret, raw_params=raw_params, + full_signature=sig_text.strip(), uf_specifiers="", + comment=comment, modifiers=mods, access=access, + is_deprecated=pending_deprecated, + deprecated_version=pending_dep_version, + deprecated_msg=pending_dep_msg, + editor_only=editor_tracker.editor_only, + ) + functions.append(fn) + pending_deprecated = False + pending_dep_version = "" + pending_dep_msg = "" + i = end_fn + continue + + # --- Default: skip line --- + i = advance_line() + pending_comment = None + + return properties, functions, nested_enums + + +# --------------------------------------------------------------------------- +# Enum at position +# --------------------------------------------------------------------------- + +def _parse_enum_at(text: str, pos: int, ue_specs: str, + comment: Optional[DocComment], editor_only: bool): + """Parse enum starting at pos. Returns (EnumInfo, end_pos) or None.""" + m = re.match(r'enum\s+(?:class\s+)?(\w+)\s*(?::\s*(\w+))?\s*\{', + text[pos:], re.DOTALL) + if not m: + return None + name = m.group(1) + underlying = m.group(2) or "" + brace_start = pos + m.end() - 1 + cl = find_matching_close(text, brace_start, '{', '}') + if cl == -1: + return None + body = text[brace_start + 1:cl] + values = _parse_enum_body(body) + end_pos = cl + 1 + while end_pos < len(text) and text[end_pos] in ' \t\r\n': + end_pos += 1 + if end_pos < len(text) and text[end_pos] == ';': + end_pos += 1 + return EnumInfo(name=name, underlying_type=underlying, ue_specifiers=ue_specs, + comment=comment, values=values, editor_only=editor_only), end_pos + + +# --------------------------------------------------------------------------- +# Class/struct at position +# --------------------------------------------------------------------------- + +def _parse_class_or_struct_at(text: str, pos: int, ue_specs: str, + comment: Optional[DocComment], macro: str): + """Parse class/struct at pos. Returns (ClassInfo, end_pos) or None.""" + m = re.match( + r'(class|struct)\s+' + r'(?:([A-Z][A-Z0-9_]+_API)\s+)?' + r'(\w+)' + r'(?:\s*:\s*([^{;]+?))?' + r'\s*\{', + text[pos:], re.DOTALL + ) + if not m: + return None + kind = m.group(1) + module_api = m.group(2) or "" + name = m.group(3) + bases_str = m.group(4) or "" + + bases = [] + for b in bases_str.split(','): + b = b.strip() + b = re.sub(r'^(?:public|protected|private)\s+', '', b).strip() + if b: + bases.append(b) + + brace_pos = pos + m.end() - 1 + cl = find_matching_close(text, brace_pos, '{', '}') + if cl == -1: + return None + + body = text[brace_pos + 1:cl] + props, fns, nested_enums = _parse_class_body(body, kind) + + end_pos = cl + 1 + while end_pos < len(text) and text[end_pos] in ' \t\r\n': + end_pos += 1 + if end_pos < len(text) and text[end_pos] == ';': + end_pos += 1 + + return ClassInfo(name=name, kind=kind, bases=bases, ue_specifiers=ue_specs, + module_api=module_api, comment=comment, properties=props, + functions=fns, nested_enums=nested_enums), end_pos + + +# --------------------------------------------------------------------------- +# Namespace free-function parsing +# --------------------------------------------------------------------------- + +def _parse_namespace_functions(body: str) -> list: + """Parse free functions in a namespace body.""" + functions = [] + i = 0 + n = len(body) + pending_comment: Optional[DocComment] = None + + while i < n: + while i < n and body[i] in ' \t\r\n': + i += 1 + if i >= n: + break + + ch = body[i] + + if ch == '#': + end = body.find('\n', i) + i = (end + 1) if end != -1 else n + continue + + if body[i:i+3] == '/**': + end = body.find('*/', i) + if end == -1: + i += 3 + continue + pending_comment = parse_doc_comment(body[i:end + 2]) + i = end + 2 + continue + + if body[i:i+2] == '/*': + end = body.find('*/', i) + i = (end + 2) if end != -1 else n + continue + + if body[i:i+2] == '//': + end = body.find('\n', i) + line_text = body[i:(end if end != -1 else n)].lstrip('/').strip() + if pending_comment is None and line_text: + pending_comment = DocComment(description=line_text) + i = (end + 1) if end != -1 else n + continue + + # typedef/using + if body[i:].startswith(('typedef ', 'using ')): + end = body.find(';', i) + i = (end + 1) if end != -1 else n + pending_comment = None + continue + + # struct/class/enum — skip + m = re.match(r'(struct|class|enum)\b', body[i:]) + if m and m.start() == 0: + bp = body.find('{', i) + semi = body.find(';', i) + if bp != -1 and (semi == -1 or bp < semi): + cl = find_matching_close(body, bp, '{', '}') + ep = cl + 1 if cl != -1 else bp + 1 + while ep < n and body[ep] in ' \t\r\n': + ep += 1 + if ep < n and body[ep] == ';': + ep += 1 + i = ep + else: + i = (semi + 1) if semi != -1 else n + pending_comment = None + continue + + # Look for function on current line + line_end = body.find('\n', i) + if line_end == -1: + line_end = n + current_line = body[i:line_end] + + paren_in_line = current_line.find('(') + if paren_in_line != -1: + abs_open = i + paren_in_line + before_paren = body[i:abs_open].rstrip() + if before_paren and re.search(r'\w$', before_paren): + cl_pos = find_matching_close(body, abs_open, '(', ')') + if cl_pos != -1: + sig_text = _API_RE.sub('', body[i:cl_pos + 1]).strip() + ret, name, raw_params, mods = _parse_function_signature(sig_text) + + skip = (not name or name in _SKIP_KEYWORDS or + name in _SKIP_MACROS) + if name and name == name.upper() and '_' in name: + skip = True + + if not skip: + end_fn = cl_pos + 1 + k = end_fn + while k < n and body[k] in ' \t\r\n': + k += 1 + if k < n and body[k] == '{': + cl2 = find_matching_close(body, k, '{', '}') + end_fn = (cl2 + 1) if cl2 != -1 else k + 1 + elif k < n and body[k] == ';': + end_fn = k + 1 + else: + end_fn = line_end + 1 + + fn = FreeFunction(name=name, full_signature=sig_text.strip(), + return_type=ret, raw_params=raw_params, + comment=pending_comment, modifiers=mods) + functions.append(fn) + pending_comment = None + i = end_fn + continue + + # Skip line + i = line_end + 1 + pending_comment = None + + return functions + + +# --------------------------------------------------------------------------- +# Module name inference +# --------------------------------------------------------------------------- + +_MODULE_SUFFIXES = [ + 'MODULE', 'MANAGER', 'SYSTEM', 'EDITOR', 'UTILS', 'TYPES', + 'SETTINGS', 'TAGS', 'ENGINE', 'CORE', 'INTERFACE', 'COMPONENT', + 'SUBSYSTEM', 'PLUGIN', 'RUNTIME', 'TASK', 'TASKS', 'GAME', +] + + +def _caps_to_camel(s: str) -> str: + """Convert ALL-CAPS identifier to CamelCase: AIMODULE->AIModule, GAMEPLAYTAGS->GameplayTags.""" + if not s: + return s + if '_' in s: + return ''.join(w.capitalize() for w in s.split('_')) + + words = [] + remaining = s + while remaining: + found = False + for suffix in sorted(_MODULE_SUFFIXES, key=len, reverse=True): + if remaining.endswith(suffix) and len(remaining) > len(suffix): + words.insert(0, suffix.capitalize()) + remaining = remaining[:-len(suffix)] + found = True + break + if not found: + # Remaining is either a short acronym (≤3 chars) or a word + words.insert(0, remaining if len(remaining) <= 3 else remaining.capitalize()) + break + return ''.join(words) + + +def _infer_module(filename: str, text: str) -> str: + m = re.search(r'\b([A-Z][A-Z0-9]+)_API\b', text) + if m: + return _caps_to_camel(m.group(1)) + return Path(filename).stem + + +# --------------------------------------------------------------------------- +# Top-level parse_header +# --------------------------------------------------------------------------- + +def parse_header(filepath: str) -> ParsedHeader: + path = Path(filepath) + text = path.read_text(encoding='utf-8', errors='replace') + + header = ParsedHeader( + filepath=str(path), + filename=path.name, + module_name=_infer_module(path.name, text), + ) + + i = 0 + n = len(text) + editor_tracker = EditorOnlyTracker() + pending_comment: Optional[DocComment] = None + + while i < n: + while i < n and text[i] in ' \t\r\n': + i += 1 + if i >= n: + break + + ch = text[i] + + # Preprocessor + if ch == '#': + end = text.find('\n', i) + pp_line = text[i:(end if end != -1 else n)] + editor_tracker.handle_line(pp_line) + i = (end + 1) if end != -1 else n + pending_comment = None + continue + + # Doc comment + if text[i:i+3] == '/**': + end = text.find('*/', i) + if end == -1: + i += 3 + continue + pending_comment = parse_doc_comment(text[i:end + 2]) + i = end + 2 + continue + + # Block comment + if text[i:i+2] == '/*': + end = text.find('*/', i) + i = (end + 2) if end != -1 else n + continue + + # Line comment + if text[i:i+2] == '//': + end = text.find('\n', i) + line_text = text[i:(end if end != -1 else n)].lstrip('/').strip() + if pending_comment is None and line_text: + pending_comment = DocComment(description=line_text) + i = (end + 1) if end != -1 else n + continue + + # Read identifier + m = re.match(r'\w+', text[i:]) + if not m: + i += 1 + continue + ident = m.group(0) + + # Delegate macros + if ident.startswith('DECLARE_') and 'DELEGATE' in ident: + inner, ep = extract_balanced(text, i, '(', ')') + delegate = _parse_delegate(ident, inner, pending_comment) + header.delegates.append(delegate) + pending_comment = None + # skip to next statement + while ep < n and text[ep] in ' \t\r\n;': + ep += 1 + i = ep + continue + + # namespace + if ident == 'namespace': + m2 = re.match(r'namespace\s+([\w:]+)\s*\{', text[i:]) + if not m2: + m2 = re.match(r'namespace\s+([\w:]+)\s*\n\s*\{', text[i:]) + if m2: + ns_name = m2.group(1) + brace_pos = i + m2.end() - 1 + cl = find_matching_close(text, brace_pos, '{', '}') + if cl != -1: + body = text[brace_pos + 1:cl] + fns = _parse_namespace_functions(body) + if fns: + ns = NamespaceInfo(name=ns_name, functions=fns) + header.namespaces.append(ns) + pending_comment = None + i = cl + 1 + else: + i += m2.end() + else: + end = text.find('\n', i) + i = (end + 1) if end != -1 else n + continue + + # UENUM + if ident == 'UENUM': + inner, ep = extract_balanced(text, i, '(', ')') + ue_specs = inner.strip() + comment = pending_comment + pending_comment = None + j = ep + while j < n and text[j] in ' \t\r\n': + j += 1 + result = _parse_enum_at(text, j, ue_specs, comment, editor_tracker.editor_only) + if result: + header.enums.append(result[0]) + i = result[1] + else: + i = j + continue + + # UCLASS / USTRUCT + if ident in ('UCLASS', 'USTRUCT'): + inner, ep = extract_balanced(text, i, '(', ')') + ue_specs = inner.strip() + comment = pending_comment + pending_comment = None + j = ep + while j < n and text[j] in ' \t\r\n': + j += 1 + result = _parse_class_or_struct_at(text, j, ue_specs, comment, ident) + if result: + header.classes.append(result[0]) + i = result[1] + else: + i = j + continue + + # Bare struct/class + if ident in ('struct', 'class'): + comment = pending_comment + pending_comment = None + result = _parse_class_or_struct_at(text, i, "", comment, "") + if result: + header.classes.append(result[0]) + i = result[1] + else: + end = text.find('\n', i) + i = (end + 1) if end != -1 else n + continue + + # Bare enum + if ident == 'enum': + result = _parse_enum_at(text, i, "", pending_comment, editor_tracker.editor_only) + if result: + header.enums.append(result[0]) + pending_comment = None + i = result[1] + else: + end = text.find('\n', i) + i = (end + 1) if end != -1 else n + continue + + # Skip line + end = text.find('\n', i) + pending_comment = None + i = (end + 1) if end != -1 else n + + return header