Просмотр исходного кода

Hook idle and session timers, poll fix

Zach Humes 3 недель назад
Родитель
Сommit
2f585014c5

+ 194 - 10
Sunrise/src/client/hooks/inactivity/inactivity_override.cpp

@@ -20,6 +20,7 @@
 #include <array>
 #include <cstddef>
 #include <cstdint>
+#include <cstdio>
 #include <string_view>
 
 #include "../../../core/logging/log.h"
@@ -47,6 +48,33 @@ constexpr std::string_view kConfigGetterText =
 /** Compiled pattern bytes of the config getter signature. */
 constexpr auto kConfigGetter = signature<signature_length(kConfigGetterText)>(kConfigGetterText);
 
+/**
+ * The controlled player's index, which the idle clock is keyed by. Stopped at its own ret, because
+ * the bytes after it belong to the next function and would tie this pattern to that one's layout.
+ */
+constexpr std::string_view kControlledIndexText =
+    "48 8B 05 39 0F 26 02 8B 80 60 04 00 00 C3";
+constexpr auto kControlledIndex =
+    signature<signature_length(kControlledIndexText)>(kControlledIndexText);
+
+/** The idle clock. It answers in the lanes' own unit, which is what makes the two comparable. */
+constexpr std::string_view kIdleClockText =
+    "40 53 48 83 EC 20 48 63 D9 48 8D 0D C8 99 A8 01 8B D3 E8 ? ? ? ? 84 C0";
+constexpr auto kIdleClock = signature<signature_length(kIdleClockText)>(kIdleClockText);
+
+/** The session clock, which is what the grace is measured against. */
+constexpr std::string_view kSessionClockText =
+    "48 83 EC 28 E8 ? ? ? ? 48 85 C0 74 ? 80 3D 4B 63 88 01 00 48 89 5C 24 20";
+constexpr auto kSessionClock = signature<signature_length(kSessionClockText)>(kSessionClockText);
+
+/**
+ * The session grace, in the object the getter returns.
+ *
+ * Read so the interface can explain a lane that has not fired yet, and never written: this module
+ * exists to stop a kick, and the only thing a shorter grace can do is bring one forward.
+ */
+constexpr std::size_t kGraceOffset = 0x84;
+
 /** Where the lanes start in the object the getter returns. */
 constexpr std::size_t kTimeoutBlockOffset = 0xAC;
 /** Milliseconds between re-applications, so an activity change cannot outlast the hold. */
@@ -59,6 +87,10 @@ constexpr std::size_t kBlockBytes = sizeof(Lanes);
 
 /** Returns the activity config object. The pointer in its global is obfuscated, so we call it. */
 using ConfigGetter = std::byte*(__fastcall*)();
+/** Answers -1 when nothing is being controlled, which is not an error and not an index. */
+using IndexGetter = std::int32_t(__fastcall*)();
+using IdleGetter = std::uint64_t(__fastcall*)(std::int32_t);
+using SessionGetter = std::uint64_t(__fastcall*)();
 
 SRWLOCK g_lock{SRWLOCK_INIT};
 ConfigGetter g_getter{};
@@ -73,6 +105,20 @@ Lanes g_captured{};
 bool g_capturedValid{};
 /** Set while a hold is in place, so releasing it writes the captured lanes exactly once. */
 bool g_holding{};
+/** The intent the last poll acted on, so a changed one does not wait for the hold interval. */
+Lanes g_intentLanes{};
+bool g_intentHolding{};
+bool g_intentValid{};
+Lanes g_live{};
+bool g_liveValid{};
+/** Never written, so unlike the lanes there is nothing to capture and put back. */
+std::uint32_t g_liveGrace{};
+bool g_liveGraceValid{};
+
+/** Null is a normal state: a build that does not match still holds its lanes without them. */
+IndexGetter g_indexGetter{};
+IdleGetter g_idleGetter{};
+SessionGetter g_sessionGetter{};
 
 /**
  * Calls the getter without faulting. The body is obfuscated game code, and it runs before the
@@ -107,6 +153,19 @@ bool g_holding{};
            && read == kBlockBytes;
 }
 
+/**
+ * @param object Config object.
+ * @param value Receives the milliseconds.
+ * @return True when Windows copied it.
+ */
+[[nodiscard]] bool read_grace(const std::byte* object, std::uint32_t& value) noexcept {
+    SIZE_T read = 0;
+    return ReadProcessMemory(
+               GetCurrentProcess(), object + kGraceOffset, &value, sizeof value, &read)
+               != FALSE
+           && read == sizeof value;
+}
+
 /**
  * Writes one run of milliseconds into the object.
  * @param object Config object.
@@ -141,25 +200,58 @@ void capture_locked(const Lanes& current) noexcept {
     g_capturedValid = true;
 }
 
+/** @return True while either switch asks for a hold. */
+[[nodiscard]] bool holds(const settings::Settings& configured) noexcept {
+    return configured.enabled || configured.custom;
+}
+
 /**
  * @param configured Current configuration.
  * @return The lanes a hold puts in place.
  */
 [[nodiscard]] Lanes held_lanes(const settings::Settings& configured) noexcept {
-    Lanes values = configured.custom ? configured.timeouts : settings::kDefaultTimeouts;
+    // A hand-edited file can carry both switches, so the blanket hold wins as the safer one.
+    const bool set = configured.custom && !configured.enabled;
+    Lanes values = set ? configured.timeouts : settings::kDefaultTimeouts;
     // Orbit is held at its longest whatever the grid or the file carries, because a timeout that
     // fires there ends a session this Client cannot re-establish.
     values[settings::kOrbitLane] = settings::kMaximumTimeoutMs;
     return values;
 }
 
-/** Writes the captured lanes back and ends the hold. */
-void release_locked(std::byte* object) noexcept {
+/**
+ * Writes the captured lanes back and ends the hold.
+ * @return True when lanes were put back, false when there was no hold to end.
+ */
+[[nodiscard]] bool release_locked(std::byte* object) noexcept {
     if (!g_holding || !g_capturedValid || !write_block(object, g_captured)) {
-        return;
+        return false;
     }
     g_holding = false;
     g_appliedValid = false;
+    return true;
+}
+
+/**
+ * Caller holds the lock. Failure is not propagated, because these are reported and never acted on:
+ * a build whose signatures have moved should still install and still hold its lanes.
+ */
+void resolve_clocks_locked() noexcept {
+    std::byte* const index = scan_main_image_unique(kControlledIndex, "inactivity_controlled_index");
+    std::byte* const idle = scan_main_image_unique(kIdleClock, "inactivity_idle_clock");
+    std::byte* const session = scan_main_image_unique(kSessionClock, "inactivity_session_clock");
+    if (index == nullptr || idle == nullptr || session == nullptr) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=inactivity stage=clocks result=fail");
+        return;
+    }
+    g_indexGetter = reinterpret_cast<IndexGetter>(index);
+    g_idleGetter = reinterpret_cast<IdleGetter>(idle);
+    g_sessionGetter = reinterpret_cast<SessionGetter>(session);
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=inactivity stage=clocks result=ok");
 }
 
 } // namespace
@@ -180,6 +272,9 @@ bool install() noexcept {
         return false;
     }
     g_getter = reinterpret_cast<ConfigGetter>(match);
+    // Scanned here rather than on first display, because the scan walks the whole image and the
+    // interface asks for these from the render thread.
+    resolve_clocks_locked();
     ReleaseSRWLockExclusive(&g_lock);
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
@@ -191,9 +286,13 @@ bool install() noexcept {
 void uninstall() noexcept {
     AcquireSRWLockExclusive(&g_lock);
     if (std::byte* const object = config_object(); object != nullptr) {
-        release_locked(object);
+        // Nothing to report on the way out; the lanes are put back or there was no hold.
+        (void)release_locked(object);
     }
     g_getter = nullptr;
+    g_indexGetter = nullptr;
+    g_idleGetter = nullptr;
+    g_sessionGetter = nullptr;
     g_object = 0;
     g_nextHoldTick = 0;
     g_applied = Lanes{};
@@ -201,19 +300,37 @@ void uninstall() noexcept {
     g_captured = Lanes{};
     g_capturedValid = false;
     g_holding = false;
+    g_intentLanes = Lanes{};
+    g_intentHolding = false;
+    g_intentValid = false;
+    g_live = Lanes{};
+    g_liveValid = false;
+    g_liveGrace = 0;
+    g_liveGraceValid = false;
     ReleaseSRWLockExclusive(&g_lock);
 }
 
 /** Holds the configured milliseconds, or puts back the ones the Client authored. */
 void poll() noexcept {
     const settings::Settings configured = settings::get();
+    const bool holding = holds(configured);
+    const Lanes desired = held_lanes(configured);
     AcquireSRWLockExclusive(&g_lock);
     const std::uint64_t now = GetTickCount64();
-    if (g_getter == nullptr || now < g_nextHoldTick) {
+    // A changed intent is the operator waiting on this call, so it does not wait for the interval.
+    const bool changed =
+        !g_intentValid || g_intentHolding != holding || (holding && g_intentLanes != desired);
+    if (g_getter == nullptr || (now < g_nextHoldTick && !changed)) {
         ReleaseSRWLockExclusive(&g_lock);
         return;
     }
     g_nextHoldTick = now + kHoldIntervalMs;
+    // Recorded before the object is reached, so neither a poll that finds no activity nor a write
+    // the Client refuses can leave the intent looking changed and skip the interval on every later
+    // frame.
+    g_intentLanes = desired;
+    g_intentHolding = holding;
+    g_intentValid = true;
     std::byte* const object = config_object();
     g_object = reinterpret_cast<std::uintptr_t>(object);
     if (object == nullptr) {
@@ -221,21 +338,53 @@ void poll() noexcept {
         return;
     }
     if (Lanes current{}; read_block(object, current)) {
+        g_live = current;
+        g_liveValid = true;
         capture_locked(current);
     }
-    if (!configured.enabled) {
-        release_locked(object);
+    if (std::uint32_t grace = 0; read_grace(object, grace)) {
+        g_liveGrace = grace;
+        g_liveGraceValid = true;
+    }
+    if (!holding) {
+        const bool released = release_locked(object);
         ReleaseSRWLockExclusive(&g_lock);
+        // Logged as its own event, so a reader can see a hold end rather than only see one start.
+        // Nothing to put back is not a failure: it is the ordinary state with the feature off.
+        if (changed) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::info,
+                             released ? "ev=inactivity stage=release result=ok"
+                                      : "ev=inactivity stage=release result=noop");
+        }
         return;
     }
     // Held rather than written once, because an activity change re-authors these lanes.
-    const Lanes desired = held_lanes(configured);
-    if (write_block(object, desired)) {
+    const bool wrote = write_block(object, desired);
+    if (wrote) {
         g_applied = desired;
         g_appliedValid = true;
         g_holding = true;
     }
+    // The Client picks which lane to time by at runtime, so the shortest one is the only figure
+    // that says when a kick can first happen without naming a lane that may not be in force.
+    const std::uint32_t shortest = *std::min_element(desired.begin(), desired.end());
     ReleaseSRWLockExclusive(&g_lock);
+    if (changed) {
+        // Only on a change, so a steady hold does not fill the log every interval.
+        std::array<char, 128> line{};
+        const int length = std::snprintf(line.data(),
+                                         line.size(),
+                                         "ev=inactivity stage=hold mode=%s shortest_ms=%u result=%s",
+                                         configured.enabled ? "disable" : "set",
+                                         shortest,
+                                         wrote ? "ok" : "fail");
+        if (length > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::info,
+                             {line.data(), static_cast<std::size_t>(length)});
+        }
+    }
 }
 
 /** Reports what the override reached. */
@@ -245,8 +394,43 @@ Status status() noexcept {
     output.resolved = g_getter != nullptr;
     output.address = g_object;
     output.captured = g_capturedValid;
+    output.live = g_live;
+    output.liveValid = g_liveValid;
+    output.liveGraceMs = g_liveGrace;
+    output.liveGraceValid = g_liveGraceValid;
     ReleaseSRWLockShared(&g_lock);
     return output;
 }
 
+Timers timers() noexcept {
+    Timers output{};
+    AcquireSRWLockShared(&g_lock);
+    const IndexGetter index = g_indexGetter;
+    const IdleGetter idle = g_idleGetter;
+    const SessionGetter session = g_sessionGetter;
+    ReleaseSRWLockShared(&g_lock);
+    if (index == nullptr || idle == nullptr || session == nullptr) {
+        return output;
+    }
+    output.resolved = true;
+    // Called outside the lock and guarded, because these bodies are obfuscated Client code and
+    // run before the Client has published the globals they read on an early frame.
+    __try {
+        const std::int32_t controlled = index();
+        if (controlled >= 0) {
+            output.idleMs = idle(controlled);
+            output.idleValid = true;
+        }
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        output.idleValid = false;
+    }
+    __try {
+        output.sessionMs = session();
+        output.sessionValid = true;
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        output.sessionValid = false;
+    }
+    return output;
+}
+
 } // namespace sunrise::client::hooks::inactivity

+ 35 - 0
Sunrise/src/client/hooks/inactivity/inactivity_override.h

@@ -1,19 +1,54 @@
 #pragma once
 
+#include <array>
 #include <cstdint>
 
+#include "../../inactivity/inactivity_settings_store.h"
+
 namespace sunrise::client::hooks::inactivity {
 
 /** What the override reached, which is what a lane not taking says. */
 struct Status {
+    /** Read back rather than assumed, so a hold that never reached the Client still reads true. */
+    std::array<std::uint32_t, client::inactivity::kActivityCount> live{};
     /** Address of the activity config object, or zero until the Client publishes one. */
     std::uintptr_t address{};
     /** Set once the config getter has been found in the image. */
     bool resolved{};
     /** Set once the Client's own lanes have been read back. */
     bool captured{};
+    bool liveValid{};
+    /** Zero is meaningful: it is the value that stops the Client gating on it at all. */
+    std::uint32_t liveGraceMs{};
+    bool liveGraceValid{};
 };
 
+/**
+ * The Client's own two clocks, read through the same getters it uses.
+ *
+ * A lane times out when idle passes its milliseconds, and nothing times out at all until the
+ * session passes the grace.
+ */
+struct Timers {
+    /** Input resets this, so it does not track the session and the two can diverge widely. */
+    std::uint64_t idleMs{};
+    std::uint64_t sessionMs{};
+    bool resolved{};
+    bool idleValid{};
+    bool sessionValid{};
+};
+
+/**
+ * Reads the Client's idle and session clocks.
+ *
+ * Every call enters Client code, so this is deliberately kept out of poll(): a caller pays for it
+ * only while it is displaying the result, and nothing pays for it otherwise. A caller that draws
+ * every frame does call it every frame. It writes nothing, and reports nothing when install could
+ * not resolve the getters.
+ * @return The clocks, with a validity flag for each.
+ */
+[[nodiscard]] Timers timers() noexcept;
+
 /**
  * Resolves the activity config getter, which the lanes are reached through.
  * @return True when it was found.

+ 4 - 0
Sunrise/src/client/inactivity/inactivity_settings_store.cpp

@@ -135,6 +135,10 @@ void parse(std::string_view text, Settings& output) noexcept {
                        kMinimumTimeoutMs,
                        kMaximumTimeoutMs);
     }
+    // A hand-edited file can carry both exclusive switches. Removing every timeout wins.
+    if (output.enabled) {
+        output.custom = false;
+    }
 }
 
 /**

+ 18 - 5
Sunrise/src/client/inactivity/inactivity_settings_store.h

@@ -10,8 +10,15 @@ namespace sunrise::client::inactivity {
 /** Activity lanes the Client keeps a separate inactivity timeout for. */
 inline constexpr std::size_t kActivityCount = 14;
 
-/** Shortest timeout offered, in milliseconds. Short enough to prove a change without a wait. */
-inline constexpr std::uint32_t kMinimumTimeoutMs = 10000;
+/**
+ * Shortest timeout offered, in milliseconds.
+ *
+ * The Client will not time any lane out until the session has outlived its own grace, which is
+ * around a minute on this build and which this module does not write. A shorter lane could not
+ * fire any sooner, so offering one would only look like a hold that is not working. A file
+ * carrying a smaller value is clamped up to this rather than refused.
+ */
+inline constexpr std::uint32_t kMinimumTimeoutMs = 60000;
 /** Longest timeout offered, in milliseconds. A day outlasts any session. */
 inline constexpr std::uint32_t kMaximumTimeoutMs = 86400000;
 
@@ -62,13 +69,19 @@ inline constexpr std::array<ActivityInfo, kActivityCount> kActivities{{
 /** Compiled lanes a fresh install holds. */
 inline constexpr std::array<std::uint32_t, kActivityCount> kDefaultTimeouts = longest_timeouts();
 
-/** Runtime inactivity configuration. This module owns it; Core settings do not carry it. */
+
+/**
+ * Runtime inactivity configuration. This module owns it; Core settings do not carry it.
+ *
+ * The two switches are exclusive, because they describe opposite behaviour: one removes every
+ * timeout and the other replaces each with a chosen one. Neither set leaves the Client's own
+ * timeouts in place.
+ */
 struct Settings {
     /** Milliseconds per lane, in block order. Held only while custom is set. */
     std::array<std::uint32_t, kActivityCount> timeouts{kDefaultTimeouts};
-    /** False puts back the lanes the Client authored. */
     bool enabled{false};
-    /** False holds every lane at its longest and leaves the stored milliseconds alone. */
+    /** Never set alongside enabled; the two ask for opposite things. */
     bool custom{false};
 };
 

+ 71 - 24
Sunrise/src/client/ui/player/player_panel.cpp

@@ -21,12 +21,15 @@ namespace toggle = core::ui::components::toggle;
 constexpr int kLaneColumns = 7;
 
 /**
- * Draws one lane's field.
+ * Draws one lane's field and, under it, the milliseconds the Client is holding in that lane now.
  * @param index Lane in block order.
  * @param configured Configuration updated on an edit.
+ * @param status What the override reached, for the live figure.
  * @return True when this lane changed.
  */
-[[nodiscard]] bool draw_lane(std::size_t index, inactivity::Settings& configured) noexcept {
+[[nodiscard]] bool draw_lane(std::size_t index,
+                             inactivity::Settings& configured,
+                             const hooks::inactivity::Status& status) noexcept {
     const bool orbit = index == inactivity::kOrbitLane;
     bool changed = false;
     ImGui::PushID(static_cast<int>(index));
@@ -35,8 +38,16 @@ constexpr int kLaneColumns = 7;
     if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
         ImGui::SetTooltip("%s", inactivity::kActivities[index].name.data());
     }
+    // The live figure is the one the Client is timing by, so it reads as active and the set value
+    // is dimmed. A lane held at its longest is timing nothing, so neither is active.
+    const std::uint32_t live = status.liveValid ? status.live[index] : 0;
+    const bool liveActive = status.liveValid && live != inactivity::kMaximumTimeoutMs;
+
     ImGui::SetNextItemWidth(-FLT_MIN);
     std::uint32_t milliseconds = configured.timeouts[index];
+    if (liveActive) {
+        ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_TextDisabled]);
+    }
     ImGui::InputScalar("##lane",
                        ImGuiDataType_U32,
                        &milliseconds,
@@ -44,34 +55,63 @@ constexpr int kLaneColumns = 7;
                        nullptr,
                        "%u",
                        ImGuiInputTextFlags_CharsDecimal);
+    if (liveActive) {
+        ImGui::PopStyleColor();
+    }
     if (ImGui::IsItemDeactivatedAfterEdit()) {
         configured.timeouts[index] =
             std::clamp(milliseconds, inactivity::kMinimumTimeoutMs, inactivity::kMaximumTimeoutMs);
         changed = true;
     }
     ImGui::EndDisabled();
+    // Outside the disabled block: the live value reads the same whether or not the field can be
+    // edited.
+    if (!status.liveValid) {
+        ImGui::TextDisabled("-");
+    } else if (liveActive) {
+        ImGui::Text("%u", live);
+    } else {
+        ImGui::TextDisabled("%u", live);
+    }
     ImGui::PopID();
     return changed;
 }
 
-/**
- * @param status What the override reached.
- * @param enabled Whether the hold is switched on.
- */
-void draw_inactivity_status(const hooks::inactivity::Status& status, bool enabled) noexcept {
-    if (!status.resolved) {
-        ImGui::TextDisabled("Timeouts not reachable in this build.");
-    } else if (status.address == 0 || !status.captured) {
-        ImGui::TextDisabled("Waiting for an activity to load.");
-    } else if (enabled) {
-        ImGui::TextDisabled("Holding this session's timeouts.");
+/** @param status What the override reached, for the live grace. */
+void draw_inactivity_clocks(const hooks::inactivity::Status& status) noexcept {
+    // Read from the draw rather than the hold, so the Client is only asked while this section is
+    // on screen to answer to.
+    const hooks::inactivity::Timers timers = hooks::inactivity::timers();
+    if (timers.idleValid) {
+        ImGui::Text("Idle %.1f s", static_cast<double>(timers.idleMs) / 1000.0);
+    } else {
+        ImGui::TextDisabled("Idle -");
+    }
+    ImGui::SameLine();
+    if (timers.sessionValid) {
+        ImGui::Text("Session %.1f s", static_cast<double>(timers.sessionMs) / 1000.0);
+    } else {
+        ImGui::TextDisabled("Session -");
+    }
+    if (!status.liveGraceValid) {
+        return;
+    }
+    const bool passed = status.liveGraceMs == 0
+                        || (timers.sessionValid && timers.sessionMs > status.liveGraceMs);
+    ImGui::SameLine();
+    // Reported, never written.
+    const double grace = static_cast<double>(status.liveGraceMs) / 1000.0;
+    if (passed) {
+        ImGui::TextDisabled("Grace %.1f s (passed)", grace);
     } else {
-        ImGui::TextDisabled("Using the client's own timeouts.");
+        ImGui::Text("Grace %.1f s (no kick until then)", grace);
     }
 }
 
 void draw_inactivity() noexcept {
     inactivity::Settings configured = inactivity::get();
+    // Taken once, so every line below and the grid all describe the same poll.
+    const hooks::inactivity::Status status = hooks::inactivity::status();
 
     ImGui::TextUnformatted("Inactivity");
     ImGui::Separator();
@@ -79,28 +119,35 @@ void draw_inactivity() noexcept {
                        "screen.");
     ImGui::Spacing();
 
+    // The two switches are exclusive, so turning this one on drops the set timeouts.
     bool changed = toggle::control("Enabled##inactivity", configured.enabled);
-    draw_inactivity_status(hooks::inactivity::status(), configured.enabled);
+    if (changed && configured.enabled) {
+        configured.custom = false;
+    }
 
     if (ImGui::CollapsingHeader("Advanced##inactivity")) {
-        changed =
-            toggle::control("Use set timeouts##inactivity_custom", configured.custom) || changed;
-        ImGui::TextDisabled("Milliseconds per activity, the unit the client's own inactivity "
-                            "overlay prints. Taken when a field is left.");
+        // A per-lane timeout has nothing to act on once every lane is already removed.
+        ImGui::BeginDisabled(configured.enabled);
+        if (toggle::control("Use set timeouts##inactivity_custom", configured.custom)) {
+            if (configured.custom) {
+                configured.enabled = false;
+            }
+            changed = true;
+        }
+        ImGui::EndDisabled();
+        ImGui::TextDisabled("In milliseconds");
         ImGui::Spacing();
-        ImGui::BeginDisabled(!configured.custom);
+        ImGui::BeginDisabled(configured.enabled || !configured.custom);
         if (ImGui::BeginTable("lanes", kLaneColumns, ImGuiTableFlags_SizingStretchSame)) {
             for (std::size_t index = 0; index < inactivity::kActivityCount; ++index) {
                 ImGui::TableNextColumn();
-                changed = draw_lane(index, configured) || changed;
+                changed = draw_lane(index, configured, status) || changed;
             }
             ImGui::EndTable();
         }
         ImGui::EndDisabled();
         ImGui::Spacing();
-        ImGui::PushTextWrapPos(0.0F);
-        ImGui::TextDisabled("Orbit is held but not editable; a timeout there needs a restart.");
-        ImGui::PopTextWrapPos();
+        draw_inactivity_clocks(status);
     }
 
     if (changed) {