Split FX out of the park plan: flag-only plans, derived online-first FX work list, two-half record release, applied-state fingerprint

This commit is contained in:
2026-08-06 16:31:59 -04:00
parent 3eae942625
commit 2423225f0c
14 changed files with 897 additions and 164 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/actions/insert_fx_action.cpp
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp
)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable insert_fx_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree fx_state guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable insert_fx_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers)
# NOT linked here, deliberately: sampler_core / pitch_shift / the filter / limiter. The
# instrument renders its own bake in its own process, which is what keeps the extension's
# link graph free of the voice engine — a link edge to it here means the design drifted.
+6
View File
@@ -19,6 +19,12 @@ reasampler_test(view_mode_model LINK view_mode_model)
reasampler_pure_library(view_tree SOURCES view_tree.cpp LINK PUBLIC view_mode_model)
reasampler_test(view_tree LINK view_tree)
reasampler_pure_library(fx_state SOURCES fx_state.cpp LINK PUBLIC view_mode_model)
reasampler_test(fx_state LINK fx_state)
reasampler_pure_library(applied_state SOURCES applied_state.cpp LINK PUBLIC view_mode_model)
reasampler_test(applied_state LINK applied_state)
reasampler_pure_library(guid_diff SOURCES guid_diff.cpp)
reasampler_test(guid_diff LINK guid_diff)
+16
View File
@@ -0,0 +1,16 @@
#include "core/view/applied_state.h"
namespace reasampler::view {
AppliedState appliedState(const ViewModeModel& model) {
AppliedState out;
out.activeModeId = model.activeModeId();
for (const auto& [guid, snap] : model.snapshots()) { // std::map ⇒ GUID order
if (!snap.flagsHeld) continue;
out.parked.push_back(
AppliedTrack{guid, snap.showInTcp, snap.showInMixer, snap.mainSend, snap.fxEnable});
}
return out;
}
} // namespace reasampler::view
+51
View File
@@ -0,0 +1,51 @@
#pragma once
// A comparable fingerprint of what an apply put into the project, so a reconcile
// can ask "does the model differ from what we applied?" rather than "did an undo
// happen?". The second question is not the same one: an undo that touched
// nothing we drive still answers yes, and repairing on it re-enables state the
// user disabled by hand.
#include <string>
#include <vector>
#include "core/view/view_mode_model.h"
namespace reasampler::view {
// One track the tool currently holds parked, and the flag values a restore would
// drive it back to. Per-FX offline is deliberately absent: it is derived, never
// persisted and never in an undo record, so a difference there is evidence of
// nothing about the model.
struct AppliedTrack {
std::string guid;
int showInTcp = 0;
int showInMixer = 0;
int mainSend = 0;
int fxEnable = 0;
bool operator==(const AppliedTrack& o) const {
return guid == o.guid && showInTcp == o.showInTcp &&
showInMixer == o.showInMixer && mainSend == o.mainSend &&
fxEnable == o.fxEnable;
}
bool operator!=(const AppliedTrack& o) const { return !(*this == o); }
};
// Membership edits, lane ownership and the solo cache are all deliberately out:
// they change constantly without changing what the apply drove, and folding them
// in would put a reconcile back to firing on every unrelated edit.
struct AppliedState {
std::string activeModeId;
std::vector<AppliedTrack> parked; // GUID order
bool operator==(const AppliedState& o) const {
return activeModeId == o.activeModeId && parked == o.parked;
}
bool operator!=(const AppliedState& o) const { return !(*this == o); }
};
// A track appears iff its park record still HOLDS its flag half — that is
// precisely the set the tool is currently driving away from the user's values.
AppliedState appliedState(const ViewModeModel& model);
} // namespace reasampler::view
+91
View File
@@ -0,0 +1,91 @@
#include "core/view/fx_state.h"
#include <cstddef>
#include <utility>
namespace reasampler::view {
namespace {
// Everything up to and including the first op naming `resumeAfter` is already
// done. Not found ⇒ nothing dropped: the identity left the chain between ticks,
// and re-walking a compare-before-write list beats trusting a cursor that no
// longer points at anything.
std::vector<FxOfflineOp> remainingAfter(const std::vector<FxOfflineOp>& ops,
const std::string& resumeAfter) {
if (resumeAfter.empty()) return ops;
for (std::size_t i = 0; i < ops.size(); ++i) {
if (ops[i].fxGuid == resumeAfter)
return std::vector<FxOfflineOp>(ops.begin() + static_cast<std::ptrdiff_t>(i) + 1,
ops.end());
}
return ops;
}
// Slot keying, deliberately: the live chain IS the target set, so its positions
// are current by construction. Identity keying would drop an FX REAPER reports no
// GUID for, and reclaim has to reach those too — the fxGuid rides along only so
// the resume point has something to match.
std::vector<FxOfflineOp> offlineTargets(const std::string& trackGuid,
const std::vector<std::string>& liveFxGuids) {
std::vector<FxOfflineOp> ops;
ops.reserve(liveFxGuids.size());
for (std::size_t i = 0; i < liveFxGuids.size(); ++i)
ops.push_back(FxOfflineOp{trackGuid, FxKeying::Slot, liveFxGuids[i],
static_cast<int>(i), true});
return ops;
}
} // namespace
std::vector<FxOfflineOp> fxRestoreTargets(const ViewModeModel& model,
const std::string& trackGuid) {
std::vector<FxOfflineOp> ops;
if (!model.holdsSnapshotFx(trackGuid)) return ops;
const TrackSnapshot* snap = model.snapshot(trackGuid);
ops.reserve(snap->fxOffline.size());
for (std::size_t i = 0; i < snap->fxOffline.size(); ++i) {
const FxOfflineState& fx = snap->fxOffline[i];
ops.push_back(FxOfflineOp{trackGuid, snap->fxKeying, fx.fxGuid,
static_cast<int>(i), fx.offline != 0});
}
return ops;
}
std::vector<FxTrackWork> desiredFxState(const ViewModeModel& model,
const std::set<std::string>& liveTrackGuids) {
std::vector<FxTrackWork> online;
std::vector<FxTrackWork> offline;
const std::string& activeMode = model.activeModeId();
for (const auto& [guid, snap] : model.snapshots()) { // std::map ⇒ GUID order
if (liveTrackGuids.count(guid) == 0) continue; // dead track — nothing to write
if (!model.leafBelongsToMode(guid, activeMode)) {
offline.push_back(FxTrackWork{guid, FxWorkKind::Offline, {}, {}});
continue;
}
// Active with the FX half already released: the chain is back online and
// the record is only waiting on its flags. Nothing owed either way.
if (!snap.fxHeld) continue;
online.push_back(
FxTrackWork{guid, FxWorkKind::Online, fxRestoreTargets(model, guid), {}});
}
online.reserve(online.size() + offline.size());
for (FxTrackWork& w : offline) online.push_back(std::move(w));
return online;
}
FxRestoreResolution resolveFxWork(const FxTrackWork& work,
const std::vector<std::string>& liveFxGuids) {
if (work.kind == FxWorkKind::Offline) {
return resolveFxRestore(
remainingAfter(offlineTargets(work.trackGuid, liveFxGuids), work.resumeAfterFxGuid),
liveFxGuids);
}
return resolveFxRestore(remainingAfter(work.targets, work.resumeAfterFxGuid), liveFxGuids);
}
} // namespace reasampler::view
+70
View File
@@ -0,0 +1,70 @@
#pragma once
// What the FX chains SHOULD be, derived from view state: an ordered per-track
// work list plus its resolution against a live chain. Derived, never persisted
// and never in an undo record — the park record's captured FX half is the only
// persisted half. Pure: FX identities are opaque strings the shell reads from
// REAPER (TrackFX_GetFXGUID) and hands in.
#include <set>
#include <string>
#include <vector>
#include "core/view/fx_offline.h"
#include "core/view/view_mode_model.h"
namespace reasampler::view {
enum class FxWorkKind {
Online, // back to the park record's captured per-FX state, verbatim
Offline, // reclaim: every live FX offline
};
// One track's whole FX move. Track-granular so a drain may stop between tracks,
// and resumable WITHIN a track by FX identity: a slot cursor would silently skip
// or repeat a plugin if the chain were edited between two ticks.
struct FxTrackWork {
std::string trackGuid;
FxWorkKind kind = FxWorkKind::Offline;
// Online: the captured targets, verbatim — never a blanket "online", which is
// exactly what would lose the plugins the USER had offline before the park.
// Offline: empty. The live chain is the target set, and only the shell can
// read it, so resolveFxWork synthesises the targets from what it is handed.
std::vector<FxOfflineOp> targets;
// Identity of the last FX this item's drain completed; empty ⇒ none yet. An
// optimisation only: every write is compare-before-write, so an identity that
// has since left the chain safely restarts the item rather than stranding it.
std::string resumeAfterFxGuid;
bool operator==(const FxTrackWork& o) const {
return trackGuid == o.trackGuid && kind == o.kind && targets == o.targets &&
resumeAfterFxGuid == o.resumeAfterFxGuid;
}
};
// The whole project's FX work, from view state alone — no folder tree, because a
// park record is only ever written for a track the planner already decided to
// park (parents and show-both leaves never get one).
//
// ONLINE WORK SORTS FIRST: onlining restores state the user owns, offlining is
// opportunistic reclaim that may be deferred indefinitely without losing
// anything. Within a kind, GUID order — deterministic, so a drain's position in
// the list survives a re-derivation.
std::vector<FxTrackWork> desiredFxState(const ViewModeModel& model,
const std::set<std::string>& liveTrackGuids);
// One track's Online targets from its park record's FX half; empty when the
// record holds none. Each target carries the record's keying, so a snapshot
// lifted from a pre-identity project still restores by slot.
std::vector<FxOfflineOp> fxRestoreTargets(const ViewModeModel& model,
const std::string& trackGuid);
// Resolves one work item against the chain as it stands, where `liveFxGuids[i]`
// is the identity of the FX at slot `i` right now (empty when REAPER reported
// none). Honors the resume point, then matches by identity: an identity that is
// not live is DROPPED, never re-pointed at a slot.
FxRestoreResolution resolveFxWork(const FxTrackWork& work,
const std::vector<std::string>& liveFxGuids);
} // namespace reasampler::view
+60 -20
View File
@@ -188,7 +188,7 @@ LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree,
return plan;
}
TrackPlan makeParkPlan(const std::string& guid, int fxCount) {
TrackPlan makeParkPlan(const std::string& guid) {
TrackPlan p;
p.flags = {
{guid, Flag::ShowInTcp, 0},
@@ -196,13 +196,6 @@ TrackPlan makeParkPlan(const std::string& guid, int fxCount) {
{guid, Flag::MainSend, 0},
{guid, Flag::FxEnable, 0},
};
// Park has no identity question to answer: it offlines every slot that is
// live right now, so slot keying IS the addressing. In production fxCount
// is always 0 here — planToggle calls this with fxCount=0 and the D2 shell
// expands the real writes itself via TrackFX_GetCount (parkFxOffline in
// shell/view/view.cpp); a nonzero fxCount only exercises this loop in tests.
for (int i = 0; i < fxCount; ++i)
p.fxOffline.push_back(FxOfflineOp{guid, FxKeying::Slot, {}, i, true});
return p;
}
@@ -214,11 +207,6 @@ TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap) {
{guid, Flag::MainSend, snap.mainSend},
{guid, Flag::FxEnable, snap.fxEnable},
};
for (std::size_t i = 0; i < snap.fxOffline.size(); ++i) {
const FxOfflineState& fx = snap.fxOffline[i];
p.fxOffline.push_back(FxOfflineOp{guid, snap.fxKeying, fx.fxGuid,
static_cast<int>(i), fx.offline != 0});
}
return p;
}
@@ -253,6 +241,47 @@ const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const {
return it == snapshots_.end() ? nullptr : &it->second;
}
bool ViewModeModel::holdsSnapshotFlags(const std::string& guid) const {
const TrackSnapshot* s = snapshot(guid);
return s && s->flagsHeld;
}
bool ViewModeModel::holdsSnapshotFx(const std::string& guid) const {
const TrackSnapshot* s = snapshot(guid);
return s && s->fxHeld;
}
namespace {
// A record with neither half left records nothing — keeping it would make
// snapshot() answer yes to a track the model no longer describes.
void eraseIfSpent(std::map<std::string, TrackSnapshot>& snapshots,
std::map<std::string, TrackSnapshot>::iterator it) {
if (!it->second.flagsHeld && !it->second.fxHeld) snapshots.erase(it);
}
} // namespace
void ViewModeModel::releaseSnapshotFlags(const std::string& guid) {
auto it = snapshots_.find(guid);
if (it == snapshots_.end()) return;
it->second.flagsHeld = false;
it->second.showInTcp = 0;
it->second.showInMixer = 0;
it->second.mainSend = 0;
it->second.fxEnable = 0;
eraseIfSpent(snapshots_, it);
}
void ViewModeModel::releaseSnapshotFx(const std::string& guid) {
auto it = snapshots_.find(guid);
if (it == snapshots_.end()) return;
it->second.fxHeld = false;
it->second.fxOffline.clear();
it->second.fxKeying = FxKeying::Identity;
eraseIfSpent(snapshots_, it);
}
std::size_t ViewModeModel::reconcile(const std::set<std::string>& liveGuids) {
// See header: snapshots are pruned, membership is not (undo-delete rationale).
soloCache_.reconcile(liveGuids);
@@ -322,11 +351,12 @@ TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string&
const bool active = leafBelongsToMode(guid, targetMode);
if (active) {
if (const TrackSnapshot* snap = snapshot(guid))
plan.restore.push_back(makeRestorePlan(guid, *snap));
// holdsSnapshotFlags, not snapshot(): a record whose FX half is still
// draining carries zeroed flags, and restoring THOSE would re-park it.
if (holdsSnapshotFlags(guid))
plan.restore.push_back(makeRestorePlan(guid, *snapshot(guid)));
} else {
// fxOffline is empty here; the D2 shell expands it via TrackFX_GetCount.
plan.park.push_back(makeParkPlan(guid, /*fxCount=*/0));
plan.park.push_back(makeParkPlan(guid));
}
}
@@ -370,11 +400,15 @@ std::string intToStr(int v) { return json::numToStr(v); }
using ObjWriter = json::Writer;
// Version ladder for the stored blob, under the FOREVER-STABLE "view_state" key.
// Only the per-FX snapshot shape has ever moved:
//
// v1 "snapshots":[{...,"fxOffline":[0,1,0]}] — offline state by SLOT
// v2 "snapshots":[{...,"fxOffline":[0,1,0], — the v1 array, still written
// "fx":[{"guid":"{..}","offline":0},...]}] — by FX IDENTITY
// v3 adds "flagsHeld"/"fxHeld", each written ONLY when false. A record's two
// halves are released independently, and absent-means-held keeps every v1/v2
// blob (and every fully-held v3 record) byte-identical to what v2 wrote. A v2
// reader sees a half-released record as fully held, which restores flags it
// should not — the same leniency direction the version field itself takes.
//
// v2 writes BOTH: "fx" is what this build reads, and the v1 array is what a build
// that predates identity keying reads — a downgrade keeps exactly the behavior it
@@ -387,7 +421,7 @@ using ObjWriter = json::Writer;
// view_state falls back to a default model, which loses every membership tag, so
// leniency is the safe direction here — the opposite call from origin_ledger,
// where a misread blob would put prune's deletion authority on bad data.
constexpr int kViewStateVersion = 2;
constexpr int kViewStateVersion = 3;
} // namespace
@@ -439,7 +473,7 @@ std::string ViewModeModel::serialize() const {
out += ']';
// snapshots: array of { guid, showInTcp, showInMixer, mainSend, fxEnable,
// fxOffline[], fx[] } — see the version ladder above.
// fxOffline[], flagsHeld?, fxHeld?, fx[] } — see the version ladder above.
root.keyBegin("snapshots");
out += '[';
{
@@ -460,6 +494,10 @@ std::string ViewModeModel::serialize() const {
e.keyBegin("fxOffline");
writeIntArray(out, bySlot);
// Absent means held — see the version ladder above.
if (!snap.flagsHeld) e.keyRaw("flagsHeld", "false");
if (!snap.fxHeld) e.keyRaw("fxHeld", "false");
// A Slot-keyed snapshot has no identities to write — emitting an
// "fx" array for it would invent the very keys it lacks.
if (snap.fxKeying == FxKeying::Identity) {
@@ -634,6 +672,8 @@ bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps
else if (k == "mainSend") { if (!r.parseInt(snap.mainSend)) return false; }
else if (k == "fxEnable") { if (!r.parseInt(snap.fxEnable)) return false; }
else if (k == "fxOffline") { if (!r.parseIntArray(bySlot)) return false; haveSlot = true; }
else if (k == "flagsHeld") { if (!r.parseBool(snap.flagsHeld)) return false; }
else if (k == "fxHeld") { if (!r.parseBool(snap.fxHeld)) return false; }
else if (k == "fx") {
if (!parseFxStates(r, byIdentity)) return false;
haveIdentity = true;
+38 -18
View File
@@ -196,6 +196,11 @@ struct FolderTree {
// parking — restore's source of truth. Ints, not bools, so a snapshot
// faithfully round-trips whatever REAPER reported (defensive against
// non-0/1 values).
//
// The two halves are released INDEPENDENTLY. The flag half is spent the moment a
// restore writes it back; the FX half has to outlive that, because it is the only
// record of which plugins the USER had offline before the park — release it with
// the flag half and a later online blanket-enables them.
struct TrackSnapshot {
int showInTcp = 0; // B_SHOWINTCP prior value
int showInMixer = 0; // B_SHOWINMIXER prior value
@@ -208,15 +213,19 @@ struct TrackSnapshot {
std::vector<FxOfflineState> fxOffline;
FxKeying fxKeying = FxKeying::Identity;
bool flagsHeld = true; // false ⇒ a restore already wrote the flags back
bool fxHeld = true; // false ⇒ the chain is back online; nothing left to restore
bool operator==(const TrackSnapshot& o) const {
return showInTcp == o.showInTcp && showInMixer == o.showInMixer &&
mainSend == o.mainSend && fxEnable == o.fxEnable &&
fxOffline == o.fxOffline && fxKeying == o.fxKeying;
fxOffline == o.fxOffline && fxKeying == o.fxKeying &&
flagsHeld == o.flagsHeld && fxHeld == o.fxHeld;
}
};
// Which scalar flag a TrackFlagOp drives. FX-offline is carried separately (it
// is per-slot, variable length) — see TrackParkPlan::fxOffline.
// Which scalar flag a TrackFlagOp drives. FX-offline is not one of these and is
// not planned beside them — it is derived from view state (core/view/fx_state).
enum class Flag {
ShowInTcp, // B_SHOWINTCP
ShowInMixer, // B_SHOWINMIXER
@@ -249,14 +258,12 @@ struct LanePlayOp {
}
};
// The complete set of operations to park one inactive leaf, or restore one
// leaf. Park uses fixed zeros; restore uses a snapshot's values. Park's
// fxOffline ops are slot-keyed (every live slot goes offline); restore's carry
// the snapshot's keying and are resolved against the live chain by
// resolveFxRestore before any write.
// One leaf's FLAG-DOMAIN plan: park writes fixed zeros, restore writes the
// snapshot's captured values. Per-FX offline is deliberately absent — it is a
// derived function of view state (core/view/fx_state), not a peer of the flags,
// and carrying it here only ever produced an always-empty park-side vector.
struct TrackPlan {
std::vector<TrackFlagOp> flags;
std::vector<FxOfflineOp> fxOffline;
std::vector<TrackFlagOp> flags;
};
// The plan for a toggle to a target mode. Parents and show-both leaves never
@@ -295,11 +302,27 @@ public:
bool setActiveMode(const std::string& modeId);
// The shell calls store before it parks a track, so restore survives a save.
// Both halves are held from here; each is released by the act that spends it.
void storeSnapshot(const std::string& guid, const TrackSnapshot& snap);
void clearSnapshot(const std::string& guid);
void clearSnapshot(const std::string& guid); // both halves at once
const TrackSnapshot* snapshot(const std::string& guid) const;
const std::map<std::string, TrackSnapshot>& snapshots() const { return snapshots_; }
// A held half is one nothing has spent yet. `snapshot()` returning non-null is
// NOT that question: a record outlives its flag half, so a caller that reads
// the flag values off a record without asking here will drive stale ones.
bool holdsSnapshotFlags(const std::string& guid) const;
bool holdsSnapshotFx(const std::string& guid) const;
// Spent by the restore that writes the captured flags back. Zeroes them, so a
// record that no longer claims them cannot be read for them either.
void releaseSnapshotFlags(const std::string& guid);
// Spent only once the chain is ACTUALLY back online — not by the flag restore.
// Releasing both together destroys the only record of which plugins the user
// had offline before the park, and a later online then blanket-enables them.
void releaseSnapshotFx(const std::string& guid);
// Drops every snapshot whose GUID is NOT in `liveGuids`, and prunes the solo
// cache the same way (see SoloCache::reconcile). Returns the count of
// SNAPSHOTS removed — the solo cache's own count is available from it directly.
@@ -330,10 +353,7 @@ public:
// restored from it. Parents and show-both leaves are never parked.
// Untagged leaves are Arrange members and park/restore accordingly. Tree
// membership is the enumeration source, so stale membership GUIDs absent
// from the tree are ignored.
//
// Park plans here carry an empty fxOffline vector — the D2 shell expands
// per-FX offline writes via TrackFX_GetCount (not available to the pure model).
// from the tree are ignored. Flag domain only; FX comes from desiredFxState.
TogglePlan planToggle(const FolderTree& tree, const std::string& targetMode) const;
// Managed lanes only, from the ownership index — the set a toggle may
@@ -357,11 +377,11 @@ private:
view::SoloCache soloCache_; // modeId -> guid -> raw I_SOLO
};
// Fixed-zero park plan for one leaf, offlining `fxCount` slots.
TrackPlan makeParkPlan(const std::string& guid, int fxCount);
// Fixed-zero park plan for one leaf.
TrackPlan makeParkPlan(const std::string& guid);
// Restore plan for one leaf from its snapshot — every flag to its captured
// value, never a default.
// value, never a default. Callers must have checked holdsSnapshotFlags.
TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap);
// Auto-tag decision: new content takes the active mode at creation; the
+3 -1
View File
@@ -17,6 +17,7 @@
#include "shell/capture/item_read.h"
#include "core/view/fx_offline.h"
#include "core/view/fx_state.h"
#include "core/view/lane_keys.h"
#include "core/view/solo_cache.h"
#include "shell/capture/track_guid.h"
@@ -471,7 +472,8 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
MediaTrack* tr = resolve(trackByGuid, guid);
if (!tr || !trackStillLive(project, tr)) continue; // stale/deleted GUID — prune
const TrackApplyResult r = restoreTrack(model, guid, tr, tp.fxOffline, tp.flags);
const std::vector<FxOfflineOp> fxOps = view::fxRestoreTargets(model, guid);
const TrackApplyResult r = restoreTrack(model, guid, tr, fxOps, tp.flags);
if (r.wrote) wrote = true;
if (r.fxMoved) undoMask |= UNDO_STATE_FX;
if (r.drops.total() > 0) { fxDrops.add(r.drops); ++fxDropTracks; }
+5 -3
View File
@@ -203,15 +203,17 @@ TrackApplyResult restoreTrack(ViewModeModel& model, const std::string& guid, Med
for (const FxOfflineWrite& w : res.writes)
if (setOfflineIfChanged(tr, w.fxIndex, w.offline)) out.fxMoved = out.wrote = true;
out.drops = res.drops;
// The chain is back online HERE, which is the only thing that spends the FX half.
model.releaseSnapshotFx(guid);
if (applyFlags(tr, flags, out.fxMoved)) out.wrote = true;
model.releaseSnapshotFlags(guid);
// Unconditional, even when every write above was elided: the snapshot is consumed
// Unconditional, even when every write above was elided: the record is consumed
// here, so this apply changed state and is not repeatable — and `wrote`, whose one
// reader asks exactly that, would be a lie at false. NOT a claim about what the
// undo record holds: the block never carries the snapshot either way, that being
// undo record holds: the block never carries the record either way, that being
// model state persisted after Undo_EndBlock2 (src/shell/view/CLAUDE.md).
model.clearSnapshot(guid);
out.wrote = true;
return out;
}