[education/kstars] kstars/ekos: feat(mcp): Add align (plate-solving) tool family

Jasem Mutlaq <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 0eb37930e7963a6be1f840b2ac8186bad3bfbd23 by Jasem Mutlaq, on behalf of Thomas Nemer.
Committed on 14/08/2026 at 18:14.
Pushed by mutlaqja into branch 'master'.

feat(mcp): Add align (plate-solving) tool family

Adds an **align** MCP tool family so an MCP client can run and monitor the Ekos plate-solving / alignment module.

Tools:
- `align_status` — module state and progress (read-only)
- `align_result` — last solve result: RA/Dec, orientation, pixel scale (read-only)
- `align_get_target_coords` — the current alignment target (read-only)
- `align_get_solver_action` — current post-solve action (read-only)
- `align_solve` — capture and solve
- `align_load_and_slew` — load an image and solve/slew to it
- `align_abort` — abort the running solve
- `align_set_target_coords` — set the alignment target
- `align_set_solver_action` — configure the post-solve action (sync, slew-to-target, nothing)
- `align_set_solver_mode` — configure the solver mode

Adds small read accessors on `Ekos::Align` (align.h / align_goto.cpp).

M  +28   -0    kstars/ekos/align/align.h
M  +16   -0    kstars/ekos/align/align_goto.cpp
M  +2    -0    kstars/ekos/manager.cpp
M  +1    -0    kstars/ekos/mcp/CMakeLists.txt
A  +401  -0    kstars/ekos/mcp/tools/aligntools.cpp     [License: GPL(v2.0+)]
A  +63   -0    kstars/ekos/mcp/tools/aligntools.h     [License: GPL(v2.0+)]

https://invent.kde.org/education/kstars/-/commit/0eb37930e7963a6be1f840b2ac8186bad3bfbd23

diff --git a/kstars/ekos/align/align.h b/kstars/ekos/align/align.h
index 0211118f2f..258a032c30 100644
--- a/kstars/ekos/align/align.h
+++ b/kstars/ekos/align/align.h
@@ -474,6 +474,34 @@ class Align : public QWidget, public Ui::Align
          */
         Q_SCRIPTABLE QList<double> getTargetCoords();
 
+        /**
+         * @brief getAlignErrorResult The pointing error of the last solve (solved vs target).
+         * @return {total, dRA, dDE, dAZ, dAL} in arcsec. Each value is the sentinel 1e6
+         *         before the first solve of the session.
+         */
+        Q_SCRIPTABLE QList<double> getAlignErrorResult();
+
+        /**
+         * @brief getAccuracyThreshold The configured alignment accuracy threshold.
+         * @return Accuracy threshold in arcsec (the alignAccuracyThreshold spin box value).
+         */
+        Q_SCRIPTABLE double getAccuracyThreshold();
+
+        /**
+         * @brief getSolverIterations Number of solve/slew passes performed in the active
+         *        slew_to_target convergence loop.
+         */
+        Q_SCRIPTABLE int getSolverIterations()
+        {
+            return solverIterations;
+        }
+
+        /**
+         * @brief getMaxSolverIterations Maximum number of solve/slew passes before the
+         *        convergence loop aborts (MAXIMUM_SOLVER_ITERATIONS).
+         */
+        Q_SCRIPTABLE int getMaxSolverIterations();
+
 
         /**
           * @brief Set the alignment target where the mount is expected to point at.
diff --git a/kstars/ekos/align/align_goto.cpp b/kstars/ekos/align/align_goto.cpp
index 36b0561bc0..484120dfdb 100644
--- a/kstars/ekos/align/align_goto.cpp
+++ b/kstars/ekos/align/align_goto.cpp
@@ -779,6 +779,22 @@ QList<double> Align::getTargetCoords()
     return QList<double>() << m_TargetCoord.ra0().Hours() << m_TargetCoord.dec0().Degrees();
 }
 
+QList<double> Align::getAlignErrorResult()
+{
+    return QList<double>() << m_TargetDiffTotal << m_TargetDiffRA << m_TargetDiffDE
+           << m_TargetDiffAZ << m_TargetDiffAL;
+}
+
+double Align::getAccuracyThreshold()
+{
+    return static_cast<double>(alignAccuracyThreshold->value());
+}
+
+int Align::getMaxSolverIterations()
+{
+    return MAXIMUM_SOLVER_ITERATIONS;
+}
+
 void Align::setTargetPositionAngle(double value)
 {
     m_TargetPositionAngle =  value;
diff --git a/kstars/ekos/manager.cpp b/kstars/ekos/manager.cpp
index 9a39784bd1..e93826e343 100644
--- a/kstars/ekos/manager.cpp
+++ b/kstars/ekos/manager.cpp
@@ -58,6 +58,7 @@
 #include "mcp/mcpserver.h"
 #include "mcp/tools/catalogtools.h"
 #include "mcp/tools/ekostools.h"
+#include "mcp/tools/aligntools.h"
 #include "mcp/tools/focusertools.h"
 #include "mcp/tools/imagetools.h"
 #include "mcp/tools/mounttools.h"
@@ -651,6 +652,7 @@ void Manager::ensureMCPServer()
         MCP::Tools::initMountTools(m_MCPServer->registry(), this);
         MCP::Tools::initFocuserTools(m_MCPServer->registry());
         MCP::Tools::initImageTools(m_MCPServer->registry(), m_MCPServer.get());
+        MCP::Tools::initAlignTools(m_MCPServer->registry(), this);
     }
 
     if (m_MCPServer->isListening())
diff --git a/kstars/ekos/mcp/CMakeLists.txt b/kstars/ekos/mcp/CMakeLists.txt
index d21e047e5a..23cddc8531 100644
--- a/kstars/ekos/mcp/CMakeLists.txt
+++ b/kstars/ekos/mcp/CMakeLists.txt
@@ -9,6 +9,7 @@ target_sources(KStarsLib PRIVATE
     tools/devicelookup.cpp
     tools/focusertools.cpp
     tools/imagetools.cpp
+    tools/aligntools.cpp
 )
 
 target_include_directories(KStarsLib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
diff --git a/kstars/ekos/mcp/tools/aligntools.cpp b/kstars/ekos/mcp/tools/aligntools.cpp
new file mode 100644
index 0000000000..6c7ffcff58
--- /dev/null
+++ b/kstars/ekos/mcp/tools/aligntools.cpp
@@ -0,0 +1,401 @@
+/*
+    SPDX-FileCopyrightText: 2026 Thomas Nemer <[email protected]>
+
+    SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#include "aligntools.h"
+#include "../mcptoolregistry.h"
+
+#include "ekos/manager.h"
+#include "ekos/align/align.h"
+#include "ekos/ekos.h"
+#include "kstarsdata.h"
+#include "skyobjects/skypoint.h"
+
+#include <QJsonObject>
+#include <QJsonValue>
+#include <QList>
+
+namespace MCP
+{
+namespace Tools
+{
+
+bool alignBusy(Ekos::AlignState state)
+{
+    return !(state == Ekos::ALIGN_IDLE   || state == Ekos::ALIGN_COMPLETE ||
+             state == Ekos::ALIGN_FAILED || state == Ekos::ALIGN_ABORTED);
+}
+
+QJsonObject makeAlignResultPayload(const QList<double> &solution, const QList<double> &fov,
+                                   const QList<double> &alignError, double accuracyArcsec)
+{
+    if (solution.size() < 3 || solution[1] <= Ekos::INVALID_VALUE)
+    {
+        QJsonObject result;
+        result["available"] = false;
+        return result;
+    }
+
+    // getSolutionResult() returns [orientation_deg, RA_deg_J2000, DEC_deg_J2000].
+    // Convert RA to hours and precess J2000 → JNow so the primary ra/dec match
+    // mount_coords / mount_sync / mount_goto (all JNow). Keep the J2000 values
+    // available separately for callers that need them.
+    const double raJ2000Hours = solution[1] / 15.0;
+    const double decJ2000Deg  = solution[2];
+
+    SkyPoint p;
+    p.setRA0(raJ2000Hours);
+    p.setDec0(decJ2000Deg);
+    p.updateCoordsNow(KStarsData::Instance()->updateNum());
+
+    QJsonObject result;
+    result["orientation"] = solution[0];
+    result["ra"]          = p.ra().Hours();
+    result["dec"]         = p.dec().Degrees();
+    result["ra_j2000"]    = raJ2000Hours;
+    result["dec_j2000"]   = decJ2000Deg;
+    if (fov.size() >= 3)
+        result["pixscale"] = fov[2];
+    result["available"]   = true;
+
+    // Error / accuracy. m_TargetDiffTotal is the 1e6 sentinel before any solve;
+    // guard against it (and any stale value) so we never leak a bogus residual.
+    // A real angular separation maxes out well under 1e6 arcsec.
+    const bool errorAvailable = alignError.size() >= 3 && alignError[0] < 1e6;
+    result["error_available"] = errorAvailable;
+    if (errorAvailable)
+    {
+        const double total = alignError[0];
+        result["error_arcsec"]     = total;
+        result["error_ra_arcsec"]  = alignError[1];
+        result["error_dec_arcsec"] = alignError[2];
+        result["accuracy_arcsec"]  = accuracyArcsec;
+        result["within_accuracy"]  = (total <= accuracyArcsec);
+
+        // Mirror the Align UI tri-state exactly (align_solution.cpp:364-369) so
+        // the MCP verdict and the on-screen green/yellow/red never disagree.
+        QString accuracyStatus;
+        if (total <= accuracyArcsec)
+            accuracyStatus = QStringLiteral("within");
+        else if (total < 1.5 * accuracyArcsec)
+            accuracyStatus = QStringLiteral("close");
+        else
+            accuracyStatus = QStringLiteral("out");
+        result["accuracy_status"] = accuracyStatus;
+    }
+    return result;
+}
+
+void initAlignTools(ToolRegistry *registry, Ekos::Manager *manager)
+{
+    // align_status — returns current align state, camera, and FOV
+    registry->registerTool(
+    {
+        "align_status",
+        "Returns the current alignment module status, active camera, and field of view dimensions, "
+        "plus busy (true while the module is mid-operation) and solver_iterations / max_iterations. "
+        "After align_solve, poll align_status until busy is false, then read align_result. The status "
+        "string 'Successful' is a transient per-solve state during the slew_to_target convergence loop, "
+        "not a terminal state — do not stop on it. solver_iterations is informational only (it resets "
+        "to 0 on success and may read 0 after a fast solve); gate polling on busy, not on the count.",
+        {},
+        [manager](const QJsonObject &, QString & error) -> QJsonValue
+        {
+            auto *align = manager->alignModule();
+            if (!align)
+            {
+                error = "Align module not available";
+                return {};
+            }
+            QList<double> fovData = align->fov();
+            QJsonObject fovObj;
+            if (fovData.size() >= 2)
+            {
+                fovObj["width"]  = fovData[0];
+                fovObj["height"] = fovData[1];
+            }
+            QJsonObject result;
+            result["status"]            = Ekos::getAlignStatusString(align->status(), false);
+            result["camera"]            = align->camera();
+            result["fov"]               = fovObj;
+            result["busy"]              = alignBusy(align->status());
+            result["solver_iterations"] = align->getSolverIterations();
+            result["max_iterations"]    = align->getMaxSolverIterations();
+            return result;
+        }
+    });
+
+    // align_solve — captures a frame and runs plate-solving asynchronously
+    registry->registerTool(
+    {
+        "align_solve",
+        "Captures a frame and starts plate-solving asynchronously. After calling this, poll align_status "
+        "until busy is false, then read align_result — within_accuracy / error_arcsec report whether it "
+        "converged. The status string 'Successful' is a per-solve transient during the slew_to_target "
+        "convergence loop, not terminal; do not stop on it. "
+        "WARNING: the post-solve action (sync / slew / nothing) is determined by the current Align module "
+        "setting (sticky from the UI or last align_set_solver_action call). Use align_set_solver_action "
+        "first to make it explicit, otherwise a stale UI selection may trigger an unintended slew.",
+        {},
+        [manager](const QJsonObject &, QString & error) -> QJsonValue
+        {
+            auto *align = manager->alignModule();
+            if (!align)
+            {
+                error = "Align module not available";
+                return {};
+            }
+            align->captureAndSolve();
+            return QJsonObject { { QStringLiteral("success"), true } };
+        }
+    });
+
+    // align_get_solver_action — returns the current post-solve action
+    registry->registerTool(
+    {
+        "align_get_solver_action",
+        "Returns the current post-solve action: 'sync', 'slew_to_target', or 'nothing'.",
+        {},
+        [manager](const QJsonObject &, QString & error) -> QJsonValue
+        {
+            auto *align = manager->alignModule();
+            if (!align)
+            {
+                error = "Align module not available";
+                return {};
+            }
+            QString action;
+            switch (align->currentGOTOMode())
+            {
+                case Ekos::Align::GOTO_SYNC:
+                    action = QStringLiteral("sync");
+                    break;
+                case Ekos::Align::GOTO_SLEW:
+                    action = QStringLiteral("slew_to_target");
+                    break;
+                case Ekos::Align::GOTO_NOTHING:
+                    action = QStringLiteral("nothing");
+                    break;
+            }
+            QJsonObject result;
+            result["action"] = action;
+            return result;
+        }
+    });
+
+    // align_set_solver_action — sets the post-solve action
+    registry->registerTool(
+    {
+        "align_set_solver_action",
+        "Sets what the Align module does after a successful plate solve. "
+        "'nothing' = leave mount alone, 'sync' = sync mount to solved coords, "
+        "'slew_to_target' = slew to coords previously set via align_set_target_coords. "
+        "Important: this is a sticky setting — always set it explicitly before calling align_solve.",
+        {
+            { "action", "string", "One of: 'nothing', 'sync', 'slew_to_target'.", true }
+        },
+        [manager](const QJsonObject & args, QString & error) -> QJsonValue
+        {
+            auto *align = manager->alignModule();
+            if (!align)
+            {
+                error = "Align module not available";
+                return {};
+            }
+            const QString action = args["action"].toString();
+            int mode = -1;
+            if (action == QLatin1String("sync"))                mode = Ekos::Align::GOTO_SYNC;
+            else if (action == QLatin1String("slew_to_target")) mode = Ekos::Align::GOTO_SLEW;
+            else if (action == QLatin1String("nothing"))        mode = Ekos::Align::GOTO_NOTHING;
+            else
+            {
+                error = "action must be one of: 'nothing', 'sync', 'slew_to_target'";
+                return {};
+            }
+            align->setSolverAction(mode);
+            return QJsonObject { { QStringLiteral("success"), true } };
+        }
+    });
+
+    // align_set_target_coords — sets the target coords used when action is 'slew_to_target'
+    registry->registerTool(
+    {
+        "align_set_target_coords",
+        "Sets the target coordinates for 'slew_to_target' action. RA in decimal hours (0-24, J2000), "
+        "Dec in decimal degrees (-90 to +90, J2000). Only meaningful when align_set_solver_action is 'slew_to_target'.",
+        {
+            { "ra",  "number", "Right Ascension in decimal hours (J2000).", true },
+            { "dec", "number", "Declination in decimal degrees (J2000).",   true }
+        },
+        [manager](const QJsonObject & args, QString & error) -> QJsonValue
+        {
+            auto *align = manager->alignModule();
+            if (!align)
+            {
+                error = "Align module not available";
+                return {};
+            }
+            if (!args.contains("ra") || !args.contains("dec"))
+            {
+                error = "ra and dec are required";
+                return {};
+            }
+            const double ra  = args["ra"].toDouble();
+            const double dec = args["dec"].toDouble();
+            align->setTargetCoords(ra, dec);
+            return QJsonObject { { QStringLiteral("success"), true } };
+        }
+    });
+
+    // align_get_target_coords — returns the current target coords
+    registry->registerTool(
+    {
+        "align_get_target_coords",
+        "Returns the current 'slew_to_target' target coordinates: ra in decimal hours (J2000), "
+        "dec in decimal degrees (J2000). Returns {\"available\": false} if no target has been "
+        "set this session.",
+        {},
+        [manager](const QJsonObject &, QString & error) -> QJsonValue
+        {
+            auto *align = manager->alignModule();
+            if (!align)
+            {
+                error = "Align module not available";
+                return {};
+            }
+            // Align::getTargetCoords reads m_TargetCoord unconditionally; before any
+            // target is set, that SkyPoint is default-constructed and dec0 reads out
+            // of the valid [-90, +90] range. Treat that as "no target set" rather
+            // than leaking the uninitialized value.
+            QList<double> tc = align->getTargetCoords();
+            if (tc.size() < 2 || tc[1] < -90.0 || tc[1] > 90.0)
+                return QJsonObject { { QStringLiteral("available"), false } };
+
+            return QJsonObject {
+                { QStringLiteral("available"), true },
+                { QStringLiteral("ra"),        tc[0] },
+                { QStringLiteral("dec"),       tc[1] }
+            };
+        }
+    });
+
+    // align_set_solver_mode — picks the plate-solver backend
+    registry->registerTool(
+    {
+        "align_set_solver_mode",
+        "Selects the plate-solver backend: 'local' uses the in-process StellarSolver (default, "
+        "what most rigs want), 'remote' delegates to an INDI astrometry parser on a connected device.",
+        {
+            { "mode", "string", "One of: 'local', 'remote'.", true }
+        },
+        [manager](const QJsonObject & args, QString & error) -> QJsonValue
+        {
+            auto *align = manager->alignModule();
+            if (!align)
+            {
+                error = "Align module not available";
+                return {};
+            }
+            const QString mode = args["mode"].toString();
+            int v = -1;
+            if (mode == QLatin1String("local"))       v = Ekos::Align::SOLVER_LOCAL;
+            else if (mode == QLatin1String("remote")) v = Ekos::Align::SOLVER_REMOTE;
+            else
+            {
+                error = "mode must be one of: 'local', 'remote'";
+                return {};
+            }
+            align->setSolverMode(v);
+            return QJsonObject { { QStringLiteral("success"), true } };
+        }
+    });
+
+    // align_result — returns the last plate-solve solution
+    registry->registerTool(
+    {
+        "align_result",
+        "Returns the last plate-solve solution. Primary fields (ra, dec) are in JNow and match the mount's "
+        "coordinate system — safe to feed to mount_sync directly. ra in decimal hours (0-24), dec in decimal "
+        "degrees (-90 to +90). ra_j2000 / dec_j2000 are the original solver output (J2000) for callers that "
+        "need them. orientation in degrees (position angle), pixscale in arcsec/pixel. "
+        "When error_available is true, also reports the pointing error solved-vs-target: "
+        "error_arcsec (total), error_ra_arcsec, error_dec_arcsec, the configured "
+        "accuracy_arcsec threshold, within_accuracy (bool), and accuracy_status "
+        "('within' | 'close' | 'out', matching the Align UI green/yellow/red). "
+        "Returns {\"available\": false} if no solution is available yet.",
+        {},
+        [manager](const QJsonObject &, QString & error) -> QJsonValue
+        {
+            auto *align = manager->alignModule();
+            if (!align)
+            {
+                error = "Align module not available";
+                return {};
+            }
+            return makeAlignResultPayload(align->getSolutionResult(), align->fov(),
+                                          align->getAlignErrorResult(), align->getAccuracyThreshold());
+        }
+    });
+
+    // align_load_and_slew — loads a FITS file and slews to its coordinates
+    registry->registerTool(
+    {
+        "align_load_and_slew",
+        "Loads a FITS file, plate-solves it, and slews the mount to the solved coordinates.",
+        {
+            { "path", "string", "Absolute path to the FITS file to load and solve.", true }
+        },
+        [manager](const QJsonObject & args, QString & error) -> QJsonValue
+        {
+            auto *align = manager->alignModule();
+            if (!align)
+            {
+                error = "Align module not available";
+                return {};
+            }
+            QString path = args["path"].toString();
+            if (path.isEmpty())
+            {
+                error = "path must not be empty";
+                return {};
+            }
+            align->loadAndSlew(path);
+            return QJsonObject{{"success", true}};
+        }
+    });
+
+    // align_abort — aborts the current alignment operation
+    registry->registerTool(
+    {
+        "align_abort",
+        "Aborts the current alignment or plate-solving operation.",
+        {},
+        [manager](const QJsonObject &, QString & error) -> QJsonValue
+        {
+            auto *align = manager->alignModule();
+            if (!align)
+            {
+                error = "Align module not available";
+                return {};
+            }
+            align->abort();
+            return QJsonObject { { QStringLiteral("success"), true } };
+        }
+    });
+
+    registry->classify(QStringLiteral("align_status"),            /*ro*/true,  /*destr*/false, /*idemp*/true);
+    registry->classify(QStringLiteral("align_solve"),             /*ro*/false, /*destr*/false, /*idemp*/false);
+    registry->classify(QStringLiteral("align_result"),            /*ro*/true,  /*destr*/false, /*idemp*/true);
+    registry->classify(QStringLiteral("align_load_and_slew"),     /*ro*/false, /*destr*/false, /*idemp*/false);
+    registry->classify(QStringLiteral("align_abort"),             /*ro*/false, /*destr*/false, /*idemp*/true);
+    registry->classify(QStringLiteral("align_get_solver_action"), /*ro*/true,  /*destr*/false, /*idemp*/true);
+    registry->classify(QStringLiteral("align_set_solver_action"), /*ro*/false, /*destr*/false, /*idemp*/true);
+    registry->classify(QStringLiteral("align_set_target_coords"), /*ro*/false, /*destr*/false, /*idemp*/true);
+    registry->classify(QStringLiteral("align_get_target_coords"), /*ro*/true,  /*destr*/false, /*idemp*/true);
+    registry->classify(QStringLiteral("align_set_solver_mode"),   /*ro*/false, /*destr*/false, /*idemp*/true);
+}
+
+} // namespace Tools
+} // namespace MCP
diff --git a/kstars/ekos/mcp/tools/aligntools.h b/kstars/ekos/mcp/tools/aligntools.h
new file mode 100644
index 0000000000..b2ae1bcf04
--- /dev/null
+++ b/kstars/ekos/mcp/tools/aligntools.h
@@ -0,0 +1,63 @@
+/*
+    SPDX-FileCopyrightText: 2026 Thomas Nemer <[email protected]>
+
+    SPDX-License-Identifier: GPL-2.0-or-later
+*/
+
+#pragma once
+
+#include <QJsonObject>
+#include <QList>
+
+#include "ekos/ekos.h"
+
+namespace Ekos
+{
+class Manager;
+}
+
+namespace MCP
+{
+
+class ToolRegistry;
+
+namespace Tools
+{
+
+void initAlignTools(ToolRegistry *registry, Ekos::Manager *manager);
+
+// Build the align_result JSON payload from raw solver outputs. Exposed for
+// regression testing of the unit (degrees → hours) and epoch (J2000 → JNow)
+// conversions, plus the error/accuracy fields.
+//
+// solution:   [orientation_deg, ra_deg_j2000, dec_deg_j2000] as returned by
+//             Ekos::Align::getSolutionResult().
+// fov:        [width, height, pixscale_arcsec_per_pixel] as returned by
+//             Ekos::Align::fov(); pixscale is read from index 2 if present.
+// alignError: {total, dRA, dDE, dAZ, dAL} in arcsec as returned by
+//             Ekos::Align::getAlignErrorResult(). The total is the 1e6 sentinel
+//             before the first solve; in that case the numeric error fields are
+//             omitted and "error_available" is false.
+// accuracyArcsec: the alignment accuracy threshold (arcsec) from
+//             Ekos::Align::getAccuracyThreshold(); drives within_accuracy and the
+//             green/yellow/red accuracy_status, mirroring the Align UI exactly.
+//
+// Returns {"available": false} when the solution is missing or invalid.
+// Otherwise returns a payload with ra/dec in JNow (hours/degrees) and
+// ra_j2000/dec_j2000 in J2000 (hours/degrees). The JNow conversion uses
+// the current KStarsData clock, so callers must ensure KStarsData is
+// initialised before invoking this in production code.
+QJsonObject makeAlignResultPayload(const QList<double> &solution, const QList<double> &fov,
+                                   const QList<double> &alignError = {}, double accuracyArcsec = 0.0);
+
+// True when the align module is mid-operation. Defined as the negation of the
+// terminal-state set {ALIGN_IDLE, ALIGN_COMPLETE, ALIGN_FAILED, ALIGN_ABORTED}
+// so it stays correct if new non-terminal states are added later. This is the
+// done/ongoing signal MCP clients poll on so they never interpret the status
+// enum themselves; in particular ALIGN_SUCCESSFUL is a per-solve transient
+// during the slew_to_target convergence loop and is reported busy=true.
+// Exposed for testing (the handler reads a live Ekos::Align::status()).
+bool alignBusy(Ekos::AlignState state);
+
+} // namespace Tools
+} // namespace MCP
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.