From f7224fcc5b50307b74c47f888c9a5be3f8a83a91 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:35:53 +0700 Subject: [PATCH 01/19] feat(M4-P0E): add typed independent MU draft state and regression harness --- .../src/M4StreamScenarioModel.hpp | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 apps/arstack_studio/src/M4StreamScenarioModel.hpp diff --git a/apps/arstack_studio/src/M4StreamScenarioModel.hpp b/apps/arstack_studio/src/M4StreamScenarioModel.hpp new file mode 100644 index 000000000..8796a71fe --- /dev/null +++ b/apps/arstack_studio/src/M4StreamScenarioModel.hpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include +#include + +// Presentation-only independent MU drafts, never the firmware source of truth. +// An explicitly opened SCD roster owns 3 IED-qualified immutable identities. +class M4StreamScenarioModel final : public QObject { + Q_OBJECT + Q_PROPERTY(bool active READ active NOTIFY changed) + Q_PROPERTY(int selectedSlot READ selectedSlot NOTIFY changed) + Q_PROPERTY(QVariantList roster READ roster NOTIFY changed) +public: + explicit M4StreamScenarioModel(QObject* parent = nullptr) : QObject(parent) {} + [[nodiscard]] bool active() const noexcept { return active_; } + [[nodiscard]] int selectedSlot() const noexcept { return selectedSlot_; } + [[nodiscard]] QVariantList roster() const; + Q_INVOKABLE bool openThree(const QVariantList& roster, const QVariantMap& initial); + Q_INVOKABLE bool saveCurrent(const QVariantMap& draft); + Q_INVOKABLE QVariantMap select(int slot, const QVariantMap& outgoing); + Q_INVOKABLE QVariantMap draftFor(int slot) const; + Q_INVOKABLE void clear(); +signals: + void changed(); +private: + struct Slot { QVariantMap identity; QVariantMap draft; }; + std::array slots_{}; + int selectedSlot_{-1}; + bool active_{false}; +}; From d50c9d7c7de6f86a568b338b9bb13abd0f3e9405 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:35:55 +0700 Subject: [PATCH 02/19] feat(M4-P0E): add typed independent MU draft state and regression harness --- .../src/M4StreamScenarioModel.cpp | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 apps/arstack_studio/src/M4StreamScenarioModel.cpp diff --git a/apps/arstack_studio/src/M4StreamScenarioModel.cpp b/apps/arstack_studio/src/M4StreamScenarioModel.cpp new file mode 100644 index 000000000..cf1fe5b65 --- /dev/null +++ b/apps/arstack_studio/src/M4StreamScenarioModel.cpp @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "M4StreamScenarioModel.hpp" +#include +#include +#include + +QVariantList M4StreamScenarioModel::roster() const { + QVariantList out; + if (active_) for (const auto& slot : slots_) out.push_back(slot.identity); + return out; +} +bool M4StreamScenarioModel::openThree(const QVariantList& roster, const QVariantMap& initial) { + if (active_ || roster.size() != 3 || initial.isEmpty()) return false; + QSet ieds, references, wireIds; + std::array staged{}; + for (int index = 0; index < 3; ++index) { + const auto identity = roster[index].toMap(); + const auto ied = identity.value(QStringLiteral("ied")).toString().trimmed(); + const auto cb = identity.value(QStringLiteral("controlBlockReference")).toString().trimmed(); + const auto sv = identity.value(QStringLiteral("svId")).toString().trimmed(); + const auto mac = identity.value(QStringLiteral("destinationMac")).toString().trimmed(); + if (ied.isEmpty() || cb.isEmpty() || sv.isEmpty() || mac.isEmpty() || + ieds.contains(ied) || references.contains(cb) || + wireIds.contains(mac + QLatin1Char('/') + sv)) return false; + ieds.insert(ied); references.insert(cb); wireIds.insert(mac + QLatin1Char('/') + sv); + staged[static_cast(index)] = {identity, initial}; + } + slots_ = std::move(staged); + selectedSlot_ = 0; active_ = true; emit changed(); + return true; +} +bool M4StreamScenarioModel::saveCurrent(const QVariantMap& draft) { + if (!active_ || selectedSlot_ < 0 || draft.isEmpty()) return false; + slots_[static_cast(selectedSlot_)].draft = draft; + return true; +} +QVariantMap M4StreamScenarioModel::select(int slot, const QVariantMap& outgoing) { + if (!active_ || slot < 0 || slot >= 3 || outgoing.isEmpty()) return {}; + slots_[static_cast(selectedSlot_)].draft = outgoing; + if (slot != selectedSlot_) { selectedSlot_ = slot; emit changed(); } + return slots_[static_cast(slot)].draft; +} +QVariantMap M4StreamScenarioModel::draftFor(int slot) const { + return active_ && slot >= 0 && slot < 3 + ? slots_[static_cast(slot)].draft : QVariantMap{}; +} +void M4StreamScenarioModel::clear() { + if (!active_) return; + active_ = false; selectedSlot_ = -1; slots_ = {}; emit changed(); +} From 5b03aa91ad8be4fdc85991d94074dc8ea6080d4a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:35:58 +0700 Subject: [PATCH 03/19] feat(M4-P0E): add typed independent MU draft state and regression harness --- .../src/M4StreamScenarioHarness.cpp | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 apps/arstack_studio/src/M4StreamScenarioHarness.cpp diff --git a/apps/arstack_studio/src/M4StreamScenarioHarness.cpp b/apps/arstack_studio/src/M4StreamScenarioHarness.cpp new file mode 100644 index 000000000..8687beec7 --- /dev/null +++ b/apps/arstack_studio/src/M4StreamScenarioHarness.cpp @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "M4StreamScenarioModel.hpp" +#include +#include + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + QVariantList roster; + for (int i = 1; i <= 3; ++i) roster.push_back(QVariantMap{ + {QStringLiteral("ied"), QStringLiteral("MU01_4I_4V_%1").arg(i)}, + {QStringLiteral("controlBlockReference"), QStringLiteral("MU%1/LLN0.MSVCB01").arg(i)}, + {QStringLiteral("svId"), QStringLiteral("MU01_SV%1").arg(i)}, + {QStringLiteral("destinationMac"), QStringLiteral("01:0C:CD:04:00:0%1").arg(i)}}); + QVariantMap initial{{QStringLiteral("hz"), 50.0}}; + M4StreamScenarioModel model; + const auto check = [](bool value, const char* reason) { + if (!value) qCritical() << "FAIL M4 pages" << reason; + return value; + }; + if (!check(model.openThree(roster, initial), "valid SCD roster") || + !check(model.active() && model.roster().size() == 3, "distinct roster identity")) return 1; + auto one = initial, two = initial, three = initial; + two.insert(QStringLiteral("hz"), 60.0); + three.insert(QStringLiteral("hz"), 0.0); + if (!check(model.select(1, one).value(QStringLiteral("hz")).toDouble() == 50.0, "MU2 seeded") || + !check(model.select(2, two).value(QStringLiteral("hz")).toDouble() == 50.0, "MU3 seeded") || + !check(model.select(0, three).value(QStringLiteral("hz")).toDouble() == 50.0, "MU1 retained") || + !check(model.draftFor(1).value(QStringLiteral("hz")).toDouble() == 60.0, "MU2 isolated") || + !check(model.draftFor(2).value(QStringLiteral("hz")).toDouble() == 0.0, "MU3 isolated")) + return 1; + model.clear(); + QVariantList duplicate = roster; + duplicate[2] = duplicate[1]; + if (!check(!model.openThree(duplicate, initial), "reject duplicate IED") || + !check(!model.active(), "bad roster doesn't activate") || + !check(model.openThree(roster, initial), "restart after rejection") || + !check(model.select(3, initial).isEmpty(), "reject fourth MU") || + !check(model.selectedSlot() == 0, "no invalid selection")) + return 1; + qInfo() << "PASS M4 P0E dedicated MU drafts and IED-qualified page selection"; + return 0; +} From d8a10f36d3686a4271b57464e44690eb7c0d99f3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:36:26 +0700 Subject: [PATCH 04/19] feat(M4-P0E): expose three validated SCD roster rows to typed Studio page model --- apps/arstack_studio/src/SclProfileModel.hpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/arstack_studio/src/SclProfileModel.hpp b/apps/arstack_studio/src/SclProfileModel.hpp index d3c78021e..2fa1778d2 100644 --- a/apps/arstack_studio/src/SclProfileModel.hpp +++ b/apps/arstack_studio/src/SclProfileModel.hpp @@ -22,6 +22,7 @@ class SclProfileModel : public QAbstractListModel { Q_PROPERTY(QVariantMap selectedProfile READ selectedProfile NOTIFY selectedProfileChanged) Q_PROPERTY(bool hasProfiles READ hasProfiles NOTIFY sourceChanged) Q_PROPERTY(bool referenceTemplateActive READ referenceTemplateActive NOTIFY sourceChanged) + Q_PROPERTY(bool m4DraftSupported READ m4DraftSupported NOTIFY sourceChanged) public: enum Roles { @@ -55,6 +56,8 @@ class SclProfileModel : public QAbstractListModel { [[nodiscard]] QVariantMap selectedProfile() const; [[nodiscard]] bool hasProfiles() const noexcept; [[nodiscard]] bool referenceTemplateActive() const noexcept; + [[nodiscard]] bool m4DraftSupported() const noexcept; + Q_INVOKABLE QVariantList m4RosterRows() const; Q_INVOKABLE bool loadFile(const QUrl& fileUrl); Q_INVOKABLE bool loadReferenceTemplate(); From f6c3a886952ea4b79e5ed0882ffd2ec6890bf133 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:36:28 +0700 Subject: [PATCH 05/19] feat(M4-P0E): expose three validated SCD roster rows to typed Studio page model --- apps/arstack_studio/src/SclProfileModel.cpp | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index 6bf78ada6..8a15f546f 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -204,6 +204,33 @@ int SclProfileModel::selectedIndex() const noexcept { return selectedIndex_; } bool SclProfileModel::hasProfiles() const noexcept { return !rows_.empty(); } bool SclProfileModel::referenceTemplateActive() const noexcept { return referenceTemplateActive_; } +bool SclProfileModel::m4DraftSupported() const noexcept { + if (rows_.size() != 3U) return false; + for (const auto& row : rows_) { + if (!row.errors.isEmpty() || !row.profile.has_value() || + row.compatibilityClass != QStringLiteral("A")) return false; + const auto& p = *row.profile; + const auto hz = p.timing.exact_frame_rate_hz(); + if (!hz.has_value() || *hz != 4000U || + !p.sample_counter_modulus.has_value() || + *p.sample_counter_modulus != 4000U || + p.payload_size_bytes != 64U) return false; + } + return true; +} + +QVariantList SclProfileModel::m4RosterRows() const { + QVariantList roster; + if (!m4DraftSupported()) return roster; + for (const auto& row : rows_) { + auto item = profileToVariantMap(*row.profile); + item.insert(QStringLiteral("ied"), row.ied); + item.insert(QStringLiteral("controlBlockReference"), row.controlBlockReference); + roster.push_back(item); + } + return roster; +} + QVariantMap SclProfileModel::selectedProfile() const { if (selectedIndex_ < 0 || selectedIndex_ >= rowCount()) return {}; const auto& row = rows_[static_cast(selectedIndex_)]; From e1ee21b9ab14915185ec10c32488b01e69d955df Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:36:31 +0700 Subject: [PATCH 06/19] feat(M4-P0E): expose three validated SCD roster rows to typed Studio page model --- apps/arstack_studio/src/main.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index a17178797..c421dc0f3 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -3,6 +3,7 @@ #include "DeviceIoWorker.hpp" #include "FirmwareManager.hpp" #include "SclProfileModel.hpp" +#include "M4StreamScenarioModel.hpp" #include "SingleInstanceGuard.hpp" #include "SmartSessionController.hpp" #include "StudioClipboard.hpp" @@ -642,6 +643,7 @@ int main(int argc, char* argv[]) { FirmwareManager firmwareService; qmlRegisterType("ARStack.Studio", 1, 0, "SclProfileModel"); + qmlRegisterType("ARStack.Studio", 1, 0, "M4StreamScenarioModel"); qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); qmlRegisterType("ARStack.Studio", 1, 0, "SvCaptureController"); qmlRegisterType("ARStack.Studio", 1, 0, "SmartSessionController"); From 970e9f76326d554dd54c9b9105b9c85f23b0e087 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:36:33 +0700 Subject: [PATCH 07/19] feat(M4-P0E): expose three validated SCD roster rows to typed Studio page model --- apps/arstack_studio/CMakeLists.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index ffc5b1923..359e4cee2 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -20,6 +20,8 @@ add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/../.. ${CMAKE_BINARY_DIR}/ariec61850- set(ARSTACK_STUDIO_CORE_SOURCES src/SclProfileModel.cpp src/SclProfileModel.hpp + src/M4StreamScenarioModel.cpp + src/M4StreamScenarioModel.hpp src/DeviceIoWorker.cpp src/DeviceIoWorker.hpp src/DeviceController.cpp @@ -210,6 +212,15 @@ target_compile_definitions(arstack_studio PRIVATE ARSTACK_STUDIO_VERSION="${PROJECT_VERSION}" ) +# Portable native GUI-side M4 pagination regression, no COM/ESP32 required. +qt_add_executable(arstack_m4_pages_harness + src/M4StreamScenarioHarness.cpp + src/M4StreamScenarioModel.cpp + src/M4StreamScenarioModel.hpp +) +target_link_libraries(arstack_m4_pages_harness PRIVATE Qt6::Core) +target_compile_features(arstack_m4_pages_harness PRIVATE cxx_std_20) + # Opt-in Windows engineering preview identity; OFF for all stable builds. option(ARSTACK_STUDIO_ENGINEERING_PREVIEW "Mark this Studio build as an isolated preview" OFF) if(ARSTACK_STUDIO_ENGINEERING_PREVIEW) From 8162808081ede4a5c153995463aa22c3f717fe35 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:37:47 +0700 Subject: [PATCH 08/19] feat(M4-P0E): use one editor and preview surface with isolated three-MU pagination --- apps/arstack_studio/qml/Main.qml | 138 +++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 835973c92..01576145e 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -38,6 +38,7 @@ ApplicationWindow { onTriggered: scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot()) } function adoptRunningBoardScenario(snapshot) { + if (m4Pages.active) return // M4 drafts are never V1 board readback. if (!device.deviceVerified || !device.running || device.scenarioApplying || reconnectScenarioRevision < 0 || reconnectScenarioRevision !== scenarioRevision || @@ -150,6 +151,12 @@ ApplicationWindow { } function markScenarioDirty() { if (restoringScenario) return + if (m4Pages.active) { + m4Pages.saveCurrent(startScenarioSnapshot(false)) + pendingApply = true + scenarioRevision += 1 + return // No legacy single-SV serial mutation in M4 draft mode. + } saveScenarioTimer.restart() pendingApply = true scenarioRevision += 1 @@ -306,7 +313,81 @@ ApplicationWindow { property double lastNotifiedSvStopIncident: 0 SclProfileModel { id: sclProfiles } + M4StreamScenarioModel { id: m4Pages } DeviceController { id: device } + property var m4LegacyDraft: null + + // One set of native editors and preview canvases. The page model stores + // independent per-IED drafts; no parallel QML waveform/phasor engines. + function restoreM4Draft(draft) { + if (!draft || !draft.current || !draft.voltage || + draft.current.length !== 4 || draft.voltage.length !== 4) return false + restoringScenario = true + const groups = [draft.current, draft.voltage] + const models = [currentModel, voltageModel] + for (var g = 0; g < 2; ++g) { + for (var row = 0; row < 4; ++row) { + const ch = groups[g][row] + models[g].setProperty(row, "magnitude", ch[0]) + models[g].setProperty(row, "phase", ch[1]) + models[g].setProperty(row, "enabled", ch[2]) + models[g].setProperty(row, "quality", ch[3]) + } + } + signalFrequency = draft.hz + previousAcFrequency = draft.hz > 0 ? draft.hz : 50 + frequencyField.text = signalFrequency.toFixed(3) + phaseLink = draft.linked === true + if (draft.ct) { + ctSaturationEnabled = draft.ct.enabled + ctDcOffsetPercent = draft.ct.dc + ctHarmonicPercent = draft.ct.harmonic + ctHarmonicOrder = draft.ct.order + ctClipPercent = draft.ct.clip + } + selectSignal(activeGroup, activeRow) + refreshPreview() + restoringScenario = false + return true + } + function m4OpenDraft() { + if (!sclProfiles.m4DraftSupported || m4Pages.active || device.running || + device.scenarioApplying || workflowBar.startQueued) { + showMessage("M4 draft requires a qualified 3-SV SCD and stopped board.", true) + return + } + if (!currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits()) return + const before = startScenarioSnapshot(false) + if (!before || !m4Pages.openThree(sclProfiles.m4RosterRows(), before)) { + showMessage("M4 stream identities invalid: cannot open three pages.", true) + return + } + m4LegacyDraft = before + autoLiveApplyTimer.stop() + autoLiveQueuedRevision = -1 + showMessage("Three MU draft pages ready. Device deploy is not enabled yet.", false) + } + function m4SelectPage(slot) { + if (!m4Pages.active || slot === m4Pages.selectedSlot) return + if (!currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits()) return + const next = m4Pages.select(slot, startScenarioSnapshot(false)) + if (!next || !restoreM4Draft(next)) + showMessage("MU switch rejected: incomplete scenario.", true) + } + function m4CloseDraft() { + if (!m4Pages.active) return + const previous = m4LegacyDraft + m4Pages.clear() + m4LegacyDraft = null + if (previous) restoreM4Draft(previous) + showMessage("M4 draft pages closed. Legacy 4I+4V editor restored.", false) + } + Connections { + target: sclProfiles + function onSourceChanged() { + if (m4Pages.active) root.m4CloseDraft() + } + } Connections { target: device @@ -715,6 +796,10 @@ ApplicationWindow { } function applyAllSignals() { + if (m4Pages.active) { + showMessage("M4 pages are offline drafts until three-stream deploy/readback is available.", true) + return false + } if (workflowBar.injectionRunning) return applyLiveScenario() if (!workflowBar.session || !workflowBar.session.liveControlReady) return false var ok = workflowBar.session.requestSetFrequency(signalFrequency) @@ -758,6 +843,7 @@ ApplicationWindow { } function applyLiveScenario() { + if (m4Pages.active) return false if (!workflowBar.injectionRunning || !workflowBar.session || !workflowBar.session.liveControlReady || device.scenarioApplying) return false // Live must transmit committed model values only. A second text cell @@ -1227,11 +1313,63 @@ ApplicationWindow { controller: root device: device profiles: sclProfiles + m4DraftOnly: m4Pages.active uiFont: root.uiFont monoFont: root.monoFont compact: root.compactLayout } + RowLayout { + id: m4PaginationStrip + visible: sclProfiles.m4DraftSupported || m4Pages.active + Layout.fillWidth: true + Layout.preferredHeight: visible ? 31 : 0 + spacing: 5 + Label { + text: "M4 · 3 SV" + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: 10 + } + Repeater { + model: m4Pages.active ? 3 : 0 + delegate: Rectangle { + required property int index + implicitWidth: 114 + implicitHeight: 26 + radius: 5 + color: m4Pages.selectedSlot === index ? studioTheme.accentSoft : studioTheme.surface2 + border.width: 1 + border.color: m4Pages.selectedSlot === index ? studioTheme.accent : studioTheme.lineSoft + Label { + anchors.centerIn: parent + text: "MU " + (index + 1) + font.family: root.uiFont + font.pixelSize: 10 + color: studioTheme.text + } + MouseArea { + anchors.fill: parent + onClicked: root.m4SelectPage(index) + } + } + } + Item { Layout.fillWidth: true } + Label { + visible: m4Pages.active + text: "DRAFT ONLY · NOT DEPLOYED" + color: studioTheme.amber + font.family: root.uiFont + font.pixelSize: 9 + } + Button { + text: m4Pages.active ? "Close MU pages" : "Open MU pages" + font.family: root.uiFont + font.pixelSize: 10 + onClicked: m4Pages.active ? root.m4CloseDraft() : root.m4OpenDraft() + } + } + SplitView { id: shellSplit Layout.fillWidth: true From e848dffa3786b70f6e63d3862bffe8ccb8ba0570 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:37:50 +0700 Subject: [PATCH 09/19] feat(M4-P0E): use one editor and preview surface with isolated three-MU pagination --- apps/arstack_studio/qml/WorkflowBar.qml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 9cf2334b5..8f923088d 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -12,6 +12,7 @@ SurfacePanel { property var controller property var device property var profiles + property bool m4DraftOnly: false property string uiFont: "Inter" property string monoFont: "Inter" property bool compact: false @@ -46,6 +47,7 @@ SurfacePanel { startIntentExpiry.stop() } function queueFirstStart() { + if (ribbon.m4DraftOnly) return false // Only a verified, physically connected board may receive deferred // START. Never arm for offline, firmware update or reconnect. if (!device.deviceVerified || !device.connected || @@ -338,6 +340,10 @@ SurfacePanel { } function requestStart() { + if (ribbon.m4DraftOnly) { + controller.showMessage("M4 draft pages are not deployed. START blocked until MULTISV deploy/readback is implemented.", true) + return + } if (ribbon.injectionRunning || ribbon.startQueued) return if (smartSession.firmwareInstallRequired) { ribbon.installPromptDeferred = false From e14e86bec62b34f0fca4270a150a5e62af380050 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:38:13 +0700 Subject: [PATCH 10/19] ci(M4-P0E): build and execute three-stream native pagination regression --- .github/workflows/arstack-studio-qt.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 4d15af2ca..0bcee1c1a 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -81,7 +81,7 @@ jobs: shell: powershell run: | cmake --build build-arstack-studio --config Release ` - --target arstack_session_harness arstack_clipboard_harness arstack_windows_ownership_harness --parallel + --target arstack_session_harness arstack_clipboard_harness arstack_windows_ownership_harness arstack_m4_pages_harness --parallel - name: S8A deterministic session harness on Windows timeout-minutes: 1 @@ -98,6 +98,14 @@ jobs: throw "S8A Windows deterministic harness failed with exit code $exitCode." } + - name: Native M4 per-MU pagination regression + timeout-minutes: 1 + shell: powershell + run: | + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_m4_pages_harness.exe' + & $exe + if ($LASTEXITCODE -ne 0) { throw "M4 native stream-page state regression failed: $LASTEXITCODE" } + - name: Native Windows clipboard roundtrip regression timeout-minutes: 1 shell: powershell @@ -226,9 +234,13 @@ jobs: run: | set -o pipefail cmake --build build-arstack-studio \ - --target arstack_studio arstack_session_harness arstack_capture_harness arstack_clipboard_harness --parallel 2 \ + --target arstack_studio arstack_session_harness arstack_capture_harness arstack_clipboard_harness arstack_m4_pages_harness --parallel 2 \ 2>&1 | tee /tmp/arstack-studio-build.log + - name: M4 three independent GUI pages regression + shell: bash + run: ./build-arstack-studio/arstack_m4_pages_harness + - name: 4I+4V reference template regression shell: bash run: | From ec6c936fff0bf343fec18171795608c67102f8db Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:38:16 +0700 Subject: [PATCH 11/19] ci(M4-P0E): build and execute three-stream native pagination regression --- .github/workflows/arstack-studio-native-preview.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index 151cdb636..883c26381 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -58,8 +58,10 @@ jobs: run: | cmake -S apps/arstack_studio -B build-studio-preview -G "Visual Studio 17 2022" -A x64 -DARIEC61850_WARNINGS_AS_ERRORS=ON -DARSTACK_STUDIO_ENGINEERING_PREVIEW=ON if ($LASTEXITCODE -ne 0) { throw 'Preview configuration failed' } - cmake --build build-studio-preview --config Release --target arstack_studio arstack_session_harness arstack_clipboard_harness arstack_capture_harness --parallel 2 + cmake --build build-studio-preview --config Release --target arstack_studio arstack_session_harness arstack_clipboard_harness arstack_capture_harness arstack_m4_pages_harness --parallel 2 if ($LASTEXITCODE -ne 0) { throw 'Native Studio preview build failed' } + & "$PWD\build-studio-preview\Release\arstack_m4_pages_harness.exe" + if ($LASTEXITCODE -ne 0) { throw 'M4 page state regression failed' } & "$PWD\build-studio-preview\Release\arstack_session_harness.exe" if ($LASTEXITCODE -ne 0) { throw 'Native session regression failed' } & "$PWD\build-studio-preview\Release\arstack_clipboard_harness.exe" From bde59a4c2e25bf2a1ae518eb491bb313f8321926 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:38:54 +0700 Subject: [PATCH 12/19] docs(M4-P0E): checkpoint draft-only safety and next deploy/readback boundary --- docs/M4_P0E_STUDIO_PAGINATION_CHECKPOINT.md | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/M4_P0E_STUDIO_PAGINATION_CHECKPOINT.md diff --git a/docs/M4_P0E_STUDIO_PAGINATION_CHECKPOINT.md b/docs/M4_P0E_STUDIO_PAGINATION_CHECKPOINT.md new file mode 100644 index 000000000..3ee269b46 --- /dev/null +++ b/docs/M4_P0E_STUDIO_PAGINATION_CHECKPOINT.md @@ -0,0 +1,22 @@ +# M4 P0E — One Studio window, three independent SCD-backed MU draft pages + +**Scope:** Native Qt/QML GUI milestone. **Draft only — no M4 firmware deployment and no physical output claim.** All existing single-SV and M3.H.3 release artifacts stay unchanged. + +## Implemented + +- SclProfileModel exposes `m4DraftSupported` and `m4RosterRows()` only when **exactly three** individually resolved SCD SV profiles have class A, exactly 4,000 fps, 4,000 counter modulus and 64-byte payload. The operator still chooses/validates the SCL profile family and counter policy; this is never inferred from the SCD file name. +- One `M4StreamScenarioModel` retains three separate IED-qualified identities and three detached deep-copy value drafts, indexed by MU slot; duplicate identities are rejected before activation. +- The **existing** SignalMatrix, SignalPreview Table/phasor/waveform, current/voltage ListModels and theme remain one instance. The operator switches MU 1/2/3; outgoing draft is captured and incoming draft is restored with the existing non-serial `restoringScenario` guard. Frequency, 8 channels, phases, quality, enable states and waveform shaping are stored individually in each draft. +- The M4 editor is explicitly opt-in, only while output is stopped. Header reads **DRAFT ONLY · NOT DEPLOYED**; `WorkflowBar.requestStart` and deferred start are refused. `applyAllSignals` and `applyLiveScenario` reject M4 drafts, preventing accidental single-SV V1 commands. A different SCL document invalidates/ends the M4 draft view; exiting restores the old single-SV editor values. +- Native `arstack_m4_pages_harness` covers independent slots, stable IDs, duplicate roster rejection and out-of-range selection. Linux and Windows Qt CI and native preview CI now build/run the harness. + +## Important incomplete capabilities + +1. **Still not a firmware deployment path.** Compile the full `M4ThreeSvRoster` and nested V2 binary, then upload through the existing single `DeviceIoWorker` queue as `MULTISV BEGIN/CHUNK/VALIDATE/COMMIT/SELECT` with transaction readback. No QML direct serial write. +2. Connect M4 page edit commits to `SVSTREAM CFG` with host `sv_scenario_hash`, accepted generation/hash and per-slot readback, including a no-op on reconnect when state is already verified. +3. Only after the device's actual three-stream capability and readback are verified should the M4 START affordance be enabled. Preserve the legacy V1 START route exactly. +4. P0F needs an exact-head paired installer/firmware and on-wire 3x4000fps physical capture with all 3 MAC/svID/APPID/VLAN and common PTP sample indices; no CI-only performance claim. + +## Design rationale + +One Qt page composition with three isolated **data snapshots** instead of three GUIs or three always-running QML canvases. The embedded single GPTimer/PTP/EMAC publisher remains separately owned; nothing in this UI milestone modifies the 250-us firmware path. \ No newline at end of file From c058436c5893aa6f009c5d5db62db273e5e026d6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:42:10 +0700 Subject: [PATCH 13/19] fix(M4-P0E): permit Qt QML type wrapper to derive from registered QObject --- apps/arstack_studio/src/M4StreamScenarioModel.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/M4StreamScenarioModel.hpp b/apps/arstack_studio/src/M4StreamScenarioModel.hpp index 8796a71fe..82cd932dc 100644 --- a/apps/arstack_studio/src/M4StreamScenarioModel.hpp +++ b/apps/arstack_studio/src/M4StreamScenarioModel.hpp @@ -7,7 +7,7 @@ // Presentation-only independent MU drafts, never the firmware source of truth. // An explicitly opened SCD roster owns 3 IED-qualified immutable identities. -class M4StreamScenarioModel final : public QObject { +class M4StreamScenarioModel : public QObject { Q_OBJECT Q_PROPERTY(bool active READ active NOTIFY changed) Q_PROPERTY(int selectedSlot READ selectedSlot NOTIFY changed) From 1ee98a35e6b971949064e682a7a1152bfcce7ad2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:45:26 +0700 Subject: [PATCH 14/19] fix(M4-P0E): block remaining direct V1 per-channel/CT/quality/zero serial mutation from MU draft view --- apps/arstack_studio/qml/Main.qml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 01576145e..7593c283a 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -746,6 +746,7 @@ ApplicationWindow { } function sendSignal(group, row) { + if (m4Pages.active) return false if (!workflowBar.session || !workflowBar.session.liveControlReady) return true var signal = groupModel(group).get(row) return workflowBar.session.requestSetSignal( @@ -912,7 +913,7 @@ ApplicationWindow { ctSaturationEnabled = enabled refreshPreview() markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) + if (!m4Pages.active && workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) workflowBar.session.requestSetCtSaturation(enabled, ctDcOffsetPercent, ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) showMessage(enabled @@ -983,7 +984,7 @@ ApplicationWindow { selectSignal(activeGroup, activeRow) refreshPreview() markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) applyAllSignals() + if (!m4Pages.active && workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) applyAllSignals() } function zeroAll() { @@ -994,7 +995,7 @@ ApplicationWindow { selectSignal(activeGroup, activeRow) refreshPreview() markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) + if (!m4Pages.active && workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) workflowBar.session.requestZero() } @@ -1003,7 +1004,7 @@ ApplicationWindow { groupModel(activeGroup).setProperty(activeRow, "quality", unsignedValue) activeQuality = unsignedValue markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) + if (!m4Pages.active && workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) workflowBar.session.requestSetQuality(activeSignal, unsignedValue) } From 8f63b139d0d69320ce638671b234e18549820416 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:47:38 +0700 Subject: [PATCH 15/19] test(M4-P0E): qualify actual ESP32 profile and regress phase/quality/waveform isolation --- apps/arstack_studio/src/SclProfileModel.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index 8a15f546f..69295059b 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -210,6 +210,8 @@ bool SclProfileModel::m4DraftSupported() const noexcept { if (!row.errors.isEmpty() || !row.profile.has_value() || row.compatibilityClass != QStringLiteral("A")) return false; const auto& p = *row.profile; + if (classify_esp32p4_sv_profile(p) != Esp32P4SvProfileSupport::ready) + return false; const auto hz = p.timing.exact_frame_rate_hz(); if (!hz.has_value() || *hz != 4000U || !p.sample_counter_modulus.has_value() || From 419fc4326f7abe1b22a274322fc14f299ef709d6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 07:47:41 +0700 Subject: [PATCH 16/19] test(M4-P0E): qualify actual ESP32 profile and regress phase/quality/waveform isolation --- .../src/M4StreamScenarioHarness.cpp | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/M4StreamScenarioHarness.cpp b/apps/arstack_studio/src/M4StreamScenarioHarness.cpp index 8687beec7..e89ee47b3 100644 --- a/apps/arstack_studio/src/M4StreamScenarioHarness.cpp +++ b/apps/arstack_studio/src/M4StreamScenarioHarness.cpp @@ -11,7 +11,15 @@ int main(int argc, char** argv) { {QStringLiteral("controlBlockReference"), QStringLiteral("MU%1/LLN0.MSVCB01").arg(i)}, {QStringLiteral("svId"), QStringLiteral("MU01_SV%1").arg(i)}, {QStringLiteral("destinationMac"), QStringLiteral("01:0C:CD:04:00:0%1").arg(i)}}); - QVariantMap initial{{QStringLiteral("hz"), 50.0}}; + QVariantMap initial{ + {QStringLiteral("hz"), 50.0}, + {QStringLiteral("current"), QVariantList{QVariantMap{ + {QStringLiteral("phase"), 0.0}, {QStringLiteral("quality"), 0U}}}}, + {QStringLiteral("voltage"), QVariantList{QVariantMap{ + {QStringLiteral("phase"), -120.0}, {QStringLiteral("quality"), 0U}}}}, + {QStringLiteral("ct"), QVariantMap{{QStringLiteral("enabled"), false}, + {QStringLiteral("harmonic"), 28.0}}} + }; M4StreamScenarioModel model; const auto check = [](bool value, const char* reason) { if (!value) qCritical() << "FAIL M4 pages" << reason; @@ -22,11 +30,21 @@ int main(int argc, char** argv) { auto one = initial, two = initial, three = initial; two.insert(QStringLiteral("hz"), 60.0); three.insert(QStringLiteral("hz"), 0.0); + two.insert(QStringLiteral("current"), QVariantList{QVariantMap{ + {QStringLiteral("phase"), 71.0}, {QStringLiteral("quality"), 0x12000000U}}}); + three.insert(QStringLiteral("ct"), QVariantMap{ + {QStringLiteral("enabled"), true}, {QStringLiteral("harmonic"), 58.0}}); if (!check(model.select(1, one).value(QStringLiteral("hz")).toDouble() == 50.0, "MU2 seeded") || !check(model.select(2, two).value(QStringLiteral("hz")).toDouble() == 50.0, "MU3 seeded") || !check(model.select(0, three).value(QStringLiteral("hz")).toDouble() == 50.0, "MU1 retained") || !check(model.draftFor(1).value(QStringLiteral("hz")).toDouble() == 60.0, "MU2 isolated") || - !check(model.draftFor(2).value(QStringLiteral("hz")).toDouble() == 0.0, "MU3 isolated")) + !check(model.draftFor(2).value(QStringLiteral("hz")).toDouble() == 0.0, "MU3 isolated") || + !check(model.draftFor(1).value(QStringLiteral("current")).toList()[0].toMap().value( + QStringLiteral("quality")).toUInt() == 0x12000000U, "MU2 quality isolated") || + !check(model.draftFor(1).value(QStringLiteral("current")).toList()[0].toMap().value( + QStringLiteral("phase")).toDouble() == 71.0, "MU2 phase isolated") || + !check(model.draftFor(2).value(QStringLiteral("ct")).toMap().value( + QStringLiteral("harmonic")).toDouble() == 58.0, "MU3 waveform isolated")) return 1; model.clear(); QVariantList duplicate = roster; From 479ed050efd238ec1e3ceadfa8063f10656efaf3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 08:10:44 +0700 Subject: [PATCH 17/19] fix(M4-P0E1): isolate legacy persistence and commit MU frequency before navigation --- apps/arstack_studio/qml/Main.qml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 7593c283a..967030b99 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -35,7 +35,10 @@ ApplicationWindow { Timer { id: saveScenarioTimer interval: 400 - onTriggered: scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot()) + onTriggered: { + if (!m4Pages.active && !root.restoringScenario) + scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot()) + } } function adoptRunningBoardScenario(snapshot) { if (m4Pages.active) return // M4 drafts are never V1 board readback. @@ -292,7 +295,7 @@ ApplicationWindow { // closes immediately after editing. Flush the local draft once, while // ListModels and Settings remain alive; no device command is sent. saveScenarioTimer.stop() - if (!root.restoringScenario) + if (!root.restoringScenario && !m4Pages.active) scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot()) // C++ owns process termination and bounded worker retirement. Do not // race it with a second QML quit path. @@ -337,6 +340,8 @@ ApplicationWindow { signalFrequency = draft.hz previousAcFrequency = draft.hz > 0 ? draft.hz : 50 frequencyField.text = signalFrequency.toFixed(3) + frequencyField.frequencyDirty = false + frequencyField.invalidInput = false phaseLink = draft.linked === true if (draft.ct) { ctSaturationEnabled = draft.ct.enabled @@ -356,26 +361,34 @@ ApplicationWindow { showMessage("M4 draft requires a qualified 3-SV SCD and stopped board.", true) return } - if (!currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits()) return + if (!root.commitFrequencyEditor() || + !currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits()) return const before = startScenarioSnapshot(false) if (!before || !m4Pages.openThree(sclProfiles.m4RosterRows(), before)) { showMessage("M4 stream identities invalid: cannot open three pages.", true) return } m4LegacyDraft = before + // Flush the legacy experiment once before the MU editor becomes active. + saveScenarioTimer.stop() + scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot()) autoLiveApplyTimer.stop() autoLiveQueuedRevision = -1 showMessage("Three MU draft pages ready. Device deploy is not enabled yet.", false) } function m4SelectPage(slot) { if (!m4Pages.active || slot === m4Pages.selectedSlot) return - if (!currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits()) return + // A tab click need not blur the focused NumericField; commit first. + if (!root.commitFrequencyEditor() || + !currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits()) return const next = m4Pages.select(slot, startScenarioSnapshot(false)) if (!next || !restoreM4Draft(next)) showMessage("MU switch rejected: incomplete scenario.", true) } function m4CloseDraft() { if (!m4Pages.active) return + // Never let the pending legacy save timer persist the outgoing MU. + saveScenarioTimer.stop() const previous = m4LegacyDraft m4Pages.clear() m4LegacyDraft = null From c1c0ce01bbe7832e4d911a3f347f7b13657c7b31 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 08:11:22 +0700 Subject: [PATCH 18/19] test(M4-P0E1): guard GUI legacy persistence and MU frequency navigation --- tests/test_m4_studio_qml_safety_contract.py | 47 +++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/test_m4_studio_qml_safety_contract.py diff --git a/tests/test_m4_studio_qml_safety_contract.py b/tests/test_m4_studio_qml_safety_contract.py new file mode 100644 index 000000000..82dafae50 --- /dev/null +++ b/tests/test_m4_studio_qml_safety_contract.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Fail-closed source contract for M4 draft/legacy boundary. + +This complements the native M4 state harness; it does not emulate Qt focus or hardware. +""" +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +QML = (ROOT / "apps/arstack_studio/qml/Main.qml").read_text(encoding="utf-8") + + +def block(start: str, end: str) -> str: + assert start in QML and end in QML, f"Missing contract anchor: {start} / {end}" + return QML.split(start, 1)[1].split(end, 1)[0] + + +class M4QmlSafetyContract(unittest.TestCase): + def test_debounced_legacy_save_is_gated(self): + save = block("id: saveScenarioTimer", "function adoptRunningBoardScenario") + self.assertIn("!m4Pages.active", save) + self.assertIn("!root.restoringScenario", save) + + def test_close_cannot_persist_active_mu_as_legacy(self): + close = block("onClosing: function(close)", "FontLoader {") + self.assertIn("saveScenarioTimer.stop()", close) + self.assertIn("!m4Pages.active", close) + + def test_open_saves_legacy_before_mu_navigation(self): + opened = block("function m4OpenDraft()", "function m4SelectPage(slot)") + self.assertIn("m4LegacyDraft = before", opened) + self.assertIn("saveScenarioTimer.stop()", opened) + self.assertIn("scenarioSettings.lastScenario", opened) + + def test_navigation_commits_frequency_before_snapshot(self): + nav = block("function m4SelectPage(slot)", "function m4CloseDraft()") + self.assertLess(nav.index("commitFrequencyEditor()"), nav.index("m4Pages.select(")) + self.assertLess(nav.index("flushPendingEdits()"), nav.index("m4Pages.select(")) + + def test_restore_clears_old_frequency_editor_state(self): + restore = block("function restoreM4Draft(draft)", "function m4OpenDraft()") + self.assertIn("frequencyField.frequencyDirty = false", restore) + self.assertIn("frequencyField.invalidInput = false", restore) + + +if __name__ == "__main__": + unittest.main() From bf7f965206a92619ba1996e8c313be836126cd0c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 08:11:35 +0700 Subject: [PATCH 19/19] ci(M4-P0E1): gate native preview on QML persistence/navigation safety regression --- .github/workflows/arstack-studio-native-preview.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index 883c26381..32bb8194c 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -5,6 +5,7 @@ on: types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/arstack_studio/**' + - 'tests/test_m4_studio_qml_safety_contract.py' # Firmware-only timing fixes MUST create a matched ESP32 image and # Windows Inno installer; do not leave users on stale embedded firmware. - 'embedded/esp32p4_smv_injector/**' @@ -45,6 +46,12 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false + - name: M4 Studio QML safety contract regression + shell: pwsh + run: | + python tests/test_m4_studio_qml_safety_contract.py + if ($LASTEXITCODE -ne 0) { throw 'M4 QML safety contract failed' } + - name: Install Qt uses: jurplel/install-qt-action@v4 with: