perf(vtank): slice 7 fix round B item 12 — materialize grid columns once per mutation, not once per frame

Every retained-UI draw re-evaluates every markup-bound column property.
Several of these did real per-row work on EVERY frame instead of returning
a cached field:

- ExcludedComponentIcons (Consumables) called ResolveComponentIcon per row,
  which itself calls IAutomation.Items.CaptureOwnedItems() — a live
  inventory snapshot — per row per frame.
- All 23 Monsters-grid columns (14 flag columns via MonsterFlagColumn, 7
  text columns, 2 move-icon columns) re-ran a LINQ .Select(...).ToArray()
  over _combatSettings.Rules every frame; Weapon/Offhand additionally
  called ItemDisplayName (another CaptureOwnedItems() scan) per row.
- Meta's MetaStateColumn/MetaConditionColumn/MetaActionColumn/
  MetaDeleteColumn/MetaMoveUpIcons/MetaMoveDownIcons and Route's
  RouteWaypointCountColumn allocated a fresh array with LINQ every frame.

Fixed by materializing each into a field, computed once at the point of
actual mutation:

- ExcludedComponentIcons: computed in RefreshItemEditors (already the sole
  owner of _excludedComponentRows).
- RouteWaypointCountColumn: computed in RefreshRouteEditor (already called
  at 13 real mutation points, including profile load).
- Meta's 6 columns: computed in RefreshMetaEditor (already called at every
  real mutation point, including profile load).
- Monsters' 23 columns: new RefreshMonsterEditor, wired into the 4 actual
  Rules-mutating methods (AddMonsterRuleCore, DeleteMonsterRuleAtCore,
  MoveMonsterRuleAtCore, UpdateMonsterActionsAt — the last already covers
  every flag toggle and value-cycle action) plus panel construction and
  ResetProfileConsumers (profile load/switch). The now-unused
  MonsterFlagColumn helper is removed.

New tests (FakeAutomation gained a CaptureOwnedItemsCallCount counter):
ExcludedComponentIconsDoesNotScanLiveInventoryOnEveryRead,
MonsterGridColumnsDoNotScanLiveInventoryOrAllocateOnEveryRead (also asserts
Assert.Same across reads), MetaAndRouteGridColumnsDoNotReallocateOnEveryRead.
All three mutation-checked by temporarily reverting to the old per-read
computation: each turned red (call-count mismatch or Assert.Same failure)
against the reverted code; restoring the cached-field getters turns each
green.

tests/AcDream.Plugins.MossTank.Tests: 674/674 (was 671/671, +3 new tests).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 278/3 skipped/281 (unchanged — App-layer surface untouched by this item).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 13:32:40 +02:00
parent da42c1fce6
commit 169a6c6e32
2 changed files with 312 additions and 63 deletions

View file

@ -117,6 +117,37 @@ internal sealed partial class MossTankPanel
private string _profileLifecycleNotice = "Macro settings are stored by character.";
private string _monsterExpressionDraft = string.Empty;
private string _monsterEditorNotice = "Add a monster name or expression, or select one in the world.";
// Fix round B item 12: the Monsters grid's 23 columns, materialized once
// per RefreshMonsterEditor (called at every real mutation point below) —
// not once per row per frame in each column's own getter. The 14 flag
// columns previously re-ran a LINQ .Select(...).ToArray() over
// _combatSettings.Rules on every draw; Weapon/Offhand additionally
// called ItemDisplayName (which itself calls
// IAutomation.Items.CaptureOwnedItems(), a live inventory snapshot) once
// per row per frame.
private IReadOnlyList<bool> _monsterFesterColumn = Array.Empty<bool>();
private IReadOnlyList<bool> _monsterBroadsideColumn = Array.Empty<bool>();
private IReadOnlyList<bool> _monsterGravityWellColumn = Array.Empty<bool>();
private IReadOnlyList<bool> _monsterImperilColumn = Array.Empty<bool>();
private IReadOnlyList<bool> _monsterYieldColumn = Array.Empty<bool>();
private IReadOnlyList<bool> _monsterVulnerabilityColumn = Array.Empty<bool>();
private IReadOnlyList<bool> _monsterAttackColumn = Array.Empty<bool>();
private IReadOnlyList<bool> _monsterRingColumn = Array.Empty<bool>();
private IReadOnlyList<bool> _monsterStreakColumn = Array.Empty<bool>();
private IReadOnlyList<bool> _monsterWeakeningColumn = Array.Empty<bool>();
private IReadOnlyList<bool> _monsterFesteringColumn = Array.Empty<bool>();
private IReadOnlyList<bool> _monsterCorruptionColumn = Array.Empty<bool>();
private IReadOnlyList<bool> _monsterDestructiveColumn = Array.Empty<bool>();
private IReadOnlyList<bool> _monsterCorrosionColumn = Array.Empty<bool>();
private IReadOnlyList<string> _monsterNameColumn = Array.Empty<string>();
private IReadOnlyList<string> _monsterPriorityColumn = Array.Empty<string>();
private IReadOnlyList<string> _monsterDamageColumn = Array.Empty<string>();
private IReadOnlyList<string> _monsterExtraVulnColumn = Array.Empty<string>();
private IReadOnlyList<string> _monsterWeaponColumn = Array.Empty<string>();
private IReadOnlyList<string> _monsterOffhandColumn = Array.Empty<string>();
private IReadOnlyList<string> _monsterPetDamageColumn = Array.Empty<string>();
private IReadOnlyList<uint> _monsterMoveUpIconsColumn = Array.Empty<uint>();
private IReadOnlyList<uint> _monsterMoveDownIconsColumn = Array.Empty<uint>();
// VVS's own HudList always has a "selected" backing int even though
// VTank's Monsters grid never reads it for anything (every real cell in
// that row is an action target, not a select-then-edit target — see
@ -174,6 +205,12 @@ internal sealed partial class MossTankPanel
private static readonly string[] HandednessCycle =
["Auto", "1-Handed", "2-Handed"];
private IReadOnlyList<string> _excludedComponentRows = Array.Empty<string>();
// Fix round B item 12: resolved once per RefreshItemEditors (a real
// state-change event), not once per row per frame — the old
// ExcludedComponentIcons getter called ResolveComponentIcon (which
// itself calls IAutomation.Items.CaptureOwnedItems(), a live inventory
// snapshot) for every row on every draw.
private IReadOnlyList<uint> _excludedComponentIcons = Array.Empty<uint>();
private int _selectedExcludedComponentRow;
private bool _buffPickerVisible;
private bool _buffPickerForBlacklist;
@ -202,6 +239,9 @@ internal sealed partial class MossTankPanel
private string _lootEditorNotice = "Add a rule or select one to edit.";
private string _lootProfileNameDraft = string.Empty;
private IReadOnlyList<string> _routeRows = Array.Empty<string>();
// Fix round B item 12: materialized in RefreshRouteEditor, not once per
// frame in the RouteWaypointCountColumn getter.
private IReadOnlyList<string> _routeWaypointCounts = Array.Empty<string>();
private int _selectedRouteWaypoint;
private string _routeProfileNameDraft = string.Empty;
private string _routeNotice = "Add the current position or a selected object.";
@ -210,6 +250,15 @@ internal sealed partial class MossTankPanel
private RouteRecallKind _routeRecallKind = RouteRecallKind.PrimaryPortal;
private RouteInsertMode _routeInsertMode = RouteInsertMode.AddToEnd;
private IReadOnlyList<string> _metaRows = Array.Empty<string>();
// Fix round B item 12: the Meta grid's 6 columns, materialized once per
// RefreshMetaEditor (already called at every real mutation point) —
// not once per row per frame in each column's own getter.
private IReadOnlyList<string> _metaDeleteColumn = Array.Empty<string>();
private IReadOnlyList<uint> _metaMoveUpIconsColumn = Array.Empty<uint>();
private IReadOnlyList<uint> _metaMoveDownIconsColumn = Array.Empty<uint>();
private IReadOnlyList<string> _metaStateColumn = Array.Empty<string>();
private IReadOnlyList<string> _metaConditionColumn = Array.Empty<string>();
private IReadOnlyList<string> _metaActionColumn = Array.Empty<string>();
private int _selectedMetaRule;
private string _metaProfileNameDraft = string.Empty;
private string _metaStateDraft = MetaEngine.DefaultState;
@ -325,6 +374,7 @@ internal sealed partial class MossTankPanel
_initialized = true;
ApplyPersistedOptionOverrides();
EnsureDefaultMonsterRule();
RefreshMonsterEditor();
RefreshItemEditors();
RefreshLootEditor();
RefreshRouteEditor();
@ -597,9 +647,7 @@ internal sealed partial class MossTankPanel
// Selected" requires a selected spell-component-class object,
// per-cell click deletes) ────────────────────────────────────────────
public IReadOnlyList<string> ExcludedComponentRows => _excludedComponentRows;
public IReadOnlyList<uint> ExcludedComponentIcons => _excludedComponentRows
.Select(ResolveComponentIcon)
.ToArray();
public IReadOnlyList<uint> ExcludedComponentIcons => _excludedComponentIcons;
public int SelectedExcludedComponentIndex => _selectedExcludedComponentRow;
public Action<int> SelectExcludedComponentRow => index =>
_selectedExcludedComponentRow = ClampRow(index, _excludedComponentRows.Count);
@ -844,10 +892,7 @@ internal sealed partial class MossTankPanel
// (docs/research/vtank-kb/08-ui-views.md §1 "Tab: Route";
// PluginCore.cs:3576-3599 — any cell click deletes the waypoint).
public IReadOnlyList<string> RouteWaypointTextColumn => _routeRows;
public IReadOnlyList<string> RouteWaypointCountColumn => Enumerable
.Range(1, _routeRows.Count)
.Select(static n => n.ToString(CultureInfo.InvariantCulture))
.ToArray();
public IReadOnlyList<string> RouteWaypointCountColumn => _routeWaypointCounts;
public Action<int> DeleteRouteWaypointAt => DeleteRouteWaypointAtCore;
public Action SelectNearestRouteWaypoint => SelectNearestRouteWaypointCore;
// VTank's own cmbNavType captions read Circular/Linear/Follow/Once
@ -1017,18 +1062,12 @@ internal sealed partial class MossTankPanel
// Monsters/Route grids), and "X" is plain ASCII the default retail
// font already renders — see NoButtonAnywhereUsesTheUnrenderableArrowGlyphs
// for why an unconfirmed glyph id is worse than a text fallback. ──
public IReadOnlyList<string> MetaDeleteColumn =>
Enumerable.Repeat("X", _metaProfile.Rules.Count).ToArray();
public IReadOnlyList<uint> MetaMoveUpIcons =>
Enumerable.Repeat(0x060028FCu, _metaProfile.Rules.Count).ToArray();
public IReadOnlyList<uint> MetaMoveDownIcons =>
Enumerable.Repeat(0x060028FDu, _metaProfile.Rules.Count).ToArray();
public IReadOnlyList<string> MetaStateColumn =>
_metaProfile.Rules.Select(static rule => rule.State).ToArray();
public IReadOnlyList<string> MetaConditionColumn =>
_metaProfile.Rules.Select(static rule => DescribeMetaCondition(rule.Condition)).ToArray();
public IReadOnlyList<string> MetaActionColumn =>
_metaProfile.Rules.Select(static rule => DescribeMetaAction(rule.Action)).ToArray();
public IReadOnlyList<string> MetaDeleteColumn => _metaDeleteColumn;
public IReadOnlyList<uint> MetaMoveUpIcons => _metaMoveUpIconsColumn;
public IReadOnlyList<uint> MetaMoveDownIcons => _metaMoveDownIconsColumn;
public IReadOnlyList<string> MetaStateColumn => _metaStateColumn;
public IReadOnlyList<string> MetaConditionColumn => _metaConditionColumn;
public IReadOnlyList<string> MetaActionColumn => _metaActionColumn;
public Action<int> DeleteMetaRuleAt => row =>
{
SelectMetaRuleCore(row);
@ -1226,12 +1265,12 @@ internal sealed partial class MossTankPanel
// ── the 14 check columns (decompile cases 0-13; order F B G I Y V A R S
// WC FC Cp DC Cs matches MonsterActionFlags's own declared bit order) ──
public IReadOnlyList<bool> MonsterFesterColumn => MonsterFlagColumn(MonsterActionFlags.Fester);
public IReadOnlyList<bool> MonsterBroadsideColumn => MonsterFlagColumn(MonsterActionFlags.Broadside);
public IReadOnlyList<bool> MonsterGravityWellColumn => MonsterFlagColumn(MonsterActionFlags.GravityWell);
public IReadOnlyList<bool> MonsterImperilColumn => MonsterFlagColumn(MonsterActionFlags.Imperil);
public IReadOnlyList<bool> MonsterYieldColumn => MonsterFlagColumn(MonsterActionFlags.Yield);
public IReadOnlyList<bool> MonsterVulnerabilityColumn => MonsterFlagColumn(MonsterActionFlags.Vulnerability);
public IReadOnlyList<bool> MonsterFesterColumn => _monsterFesterColumn;
public IReadOnlyList<bool> MonsterBroadsideColumn => _monsterBroadsideColumn;
public IReadOnlyList<bool> MonsterGravityWellColumn => _monsterGravityWellColumn;
public IReadOnlyList<bool> MonsterImperilColumn => _monsterImperilColumn;
public IReadOnlyList<bool> MonsterYieldColumn => _monsterYieldColumn;
public IReadOnlyList<bool> MonsterVulnerabilityColumn => _monsterVulnerabilityColumn;
// Case 6 ("A") assigns inverted in VTank's own obfuscated field
// (a10.t = !checkbox), but that inversion is purely an artifact of
// VTank's internal storage — the CHECKBOX ITSELF still shows "checked
@ -1239,14 +1278,14 @@ internal sealed partial class MossTankPanel
// the field back). Our MonsterActionFlags.Attack is already declared in
// the un-inverted, on-screen sense, so no inversion is needed here: this
// column reads/writes Attack directly and shows exactly what VTank shows.
public IReadOnlyList<bool> MonsterAttackColumn => MonsterFlagColumn(MonsterActionFlags.Attack);
public IReadOnlyList<bool> MonsterRingColumn => MonsterFlagColumn(MonsterActionFlags.Ring);
public IReadOnlyList<bool> MonsterStreakColumn => MonsterFlagColumn(MonsterActionFlags.Streak);
public IReadOnlyList<bool> MonsterWeakeningColumn => MonsterFlagColumn(MonsterActionFlags.WeakeningCurse);
public IReadOnlyList<bool> MonsterFesteringColumn => MonsterFlagColumn(MonsterActionFlags.FesteringCurse);
public IReadOnlyList<bool> MonsterCorruptionColumn => MonsterFlagColumn(MonsterActionFlags.Corruption);
public IReadOnlyList<bool> MonsterDestructiveColumn => MonsterFlagColumn(MonsterActionFlags.DestructiveCurse);
public IReadOnlyList<bool> MonsterCorrosionColumn => MonsterFlagColumn(MonsterActionFlags.Corrosion);
public IReadOnlyList<bool> MonsterAttackColumn => _monsterAttackColumn;
public IReadOnlyList<bool> MonsterRingColumn => _monsterRingColumn;
public IReadOnlyList<bool> MonsterStreakColumn => _monsterStreakColumn;
public IReadOnlyList<bool> MonsterWeakeningColumn => _monsterWeakeningColumn;
public IReadOnlyList<bool> MonsterFesteringColumn => _monsterFesteringColumn;
public IReadOnlyList<bool> MonsterCorruptionColumn => _monsterCorruptionColumn;
public IReadOnlyList<bool> MonsterDestructiveColumn => _monsterDestructiveColumn;
public IReadOnlyList<bool> MonsterCorrosionColumn => _monsterCorrosionColumn;
public Action<int> ToggleMonsterFesterAt => row => ToggleMonsterFlagAt(row, MonsterActionFlags.Fester);
public Action<int> ToggleMonsterBroadsideAt => row => ToggleMonsterFlagAt(row, MonsterActionFlags.Broadside);
@ -1264,26 +1303,13 @@ internal sealed partial class MossTankPanel
public Action<int> ToggleMonsterCorrosionAt => row => ToggleMonsterFlagAt(row, MonsterActionFlags.Corrosion);
// ── the 7 text columns (decompile cases 14-20) ──────────────────────────
public IReadOnlyList<string> MonsterNameColumn =>
_combatSettings.Rules.Select(static r => r.Expression).ToArray();
public IReadOnlyList<string> MonsterPriorityColumn =>
_combatSettings.Rules.Select(
static r => r.Actions.BoundedPriority.ToString(CultureInfo.InvariantCulture))
.ToArray();
public IReadOnlyList<string> MonsterDamageColumn =>
_combatSettings.Rules.Select(static r => DamageTypeDisplay(r.Actions.DamageType)).ToArray();
public IReadOnlyList<string> MonsterExtraVulnColumn =>
_combatSettings.Rules.Select(static r => DamageTypeDisplay(r.Actions.ExtraVulnerability)).ToArray();
public IReadOnlyList<string> MonsterWeaponColumn =>
_combatSettings.Rules.Select(
r => ItemDisplayName(r.Actions.WeaponObjectId, r.Actions.WeaponName))
.ToArray();
public IReadOnlyList<string> MonsterOffhandColumn =>
_combatSettings.Rules.Select(
r => ItemDisplayName(r.Actions.OffhandObjectId, r.Actions.OffhandName))
.ToArray();
public IReadOnlyList<string> MonsterPetDamageColumn =>
_combatSettings.Rules.Select(static r => DamageTypeDisplay(r.Actions.PetDamageType)).ToArray();
public IReadOnlyList<string> MonsterNameColumn => _monsterNameColumn;
public IReadOnlyList<string> MonsterPriorityColumn => _monsterPriorityColumn;
public IReadOnlyList<string> MonsterDamageColumn => _monsterDamageColumn;
public IReadOnlyList<string> MonsterExtraVulnColumn => _monsterExtraVulnColumn;
public IReadOnlyList<string> MonsterWeaponColumn => _monsterWeaponColumn;
public IReadOnlyList<string> MonsterOffhandColumn => _monsterOffhandColumn;
public IReadOnlyList<string> MonsterPetDamageColumn => _monsterPetDamageColumn;
// Case 14: clicking the Name cell of any non-DEFAULT row deletes it
// (PluginCore.cs:7948-7955's `if (A_1 != 0) { ...; b(A_1); }`, where
@ -1325,10 +1351,8 @@ internal sealed partial class MossTankPanel
// ── the 2 icon columns (decompile cases 21/22 — move up/down; DAT ids
// 0x060028FC/0x060028FD, docs/plans/2026-09-07-campaign-vt-slice7-tabs.md
// S7.3) ──────────────────────────────────────────────────────────────
public IReadOnlyList<uint> MonsterMoveUpIcons =>
Enumerable.Repeat(0x060028FCu, _combatSettings.Rules.Count).ToArray();
public IReadOnlyList<uint> MonsterMoveDownIcons =>
Enumerable.Repeat(0x060028FDu, _combatSettings.Rules.Count).ToArray();
public IReadOnlyList<uint> MonsterMoveUpIcons => _monsterMoveUpIconsColumn;
public IReadOnlyList<uint> MonsterMoveDownIcons => _monsterMoveDownIconsColumn;
public Action<int> MoveMonsterRuleUpAt => row => MoveMonsterRuleAtCore(row, -1);
public Action<int> MoveMonsterRuleDownAt => row => MoveMonsterRuleAtCore(row, 1);
@ -1735,6 +1759,10 @@ internal sealed partial class MossTankPanel
_consumableRows.Count);
_excludedComponentRows = ParseExcludedComponents(
_buffSettings.BlacklistedSpellComponents);
var excludedIcons = new uint[_excludedComponentRows.Count];
for (int i = 0; i < excludedIcons.Length; i++)
excludedIcons[i] = ResolveComponentIcon(_excludedComponentRows[i]);
_excludedComponentIcons = excludedIcons;
_selectedExcludedComponentRow = ClampRow(
_selectedExcludedComponentRow,
_excludedComponentRows.Count);
@ -2143,6 +2171,10 @@ internal sealed partial class MossTankPanel
$"{(index == active && _navigationSettings.Enabled ? "<<" : " ")} "
+ waypoint.DisplayText)
.ToArray();
var counts = new string[_routeRows.Count];
for (int i = 0; i < counts.Length; i++)
counts[i] = (i + 1).ToString(CultureInfo.InvariantCulture);
_routeWaypointCounts = counts;
}
private void AddRoutePointCore()
@ -2502,8 +2534,95 @@ internal sealed partial class MossTankPanel
_combatSettings.Rules.Add(new MonsterRule("DEFAULT", 0));
}
private IReadOnlyList<bool> MonsterFlagColumn(MonsterActionFlags flag) =>
_combatSettings.Rules.Select(r => (r.Actions.Flags & flag) != 0).ToArray();
/// <summary>
/// Fix round B item 12: materializes every Monsters-grid column
/// (14 flags + 7 text + 2 move icons = 23) from
/// <see cref="_combatSettings"/>'s <c>Rules</c> in one pass. Called from
/// every real mutation point (Add/Delete/Move/UpdateMonsterActionsAt)
/// plus panel construction and profile reload — never from a column's
/// own getter, which previously re-ran this same work (plus, for
/// Weapon/Offhand, a live <c>CaptureOwnedItems()</c> scan) on every
/// single frame.
/// </summary>
private void RefreshMonsterEditor()
{
int count = _combatSettings.Rules.Count;
var fester = new bool[count];
var broadside = new bool[count];
var gravityWell = new bool[count];
var imperil = new bool[count];
var yield = new bool[count];
var vulnerability = new bool[count];
var attack = new bool[count];
var ring = new bool[count];
var streak = new bool[count];
var weakening = new bool[count];
var festering = new bool[count];
var corruption = new bool[count];
var destructive = new bool[count];
var corrosion = new bool[count];
var names = new string[count];
var priorities = new string[count];
var damage = new string[count];
var extraVuln = new string[count];
var weapon = new string[count];
var offhand = new string[count];
var petDamage = new string[count];
var moveUpIcons = new uint[count];
var moveDownIcons = new uint[count];
for (int i = 0; i < count; i++)
{
MonsterRule rule = _combatSettings.Rules[i];
MonsterRuleActions actions = rule.Actions;
MonsterActionFlags flags = actions.Flags;
fester[i] = (flags & MonsterActionFlags.Fester) != 0;
broadside[i] = (flags & MonsterActionFlags.Broadside) != 0;
gravityWell[i] = (flags & MonsterActionFlags.GravityWell) != 0;
imperil[i] = (flags & MonsterActionFlags.Imperil) != 0;
yield[i] = (flags & MonsterActionFlags.Yield) != 0;
vulnerability[i] = (flags & MonsterActionFlags.Vulnerability) != 0;
attack[i] = (flags & MonsterActionFlags.Attack) != 0;
ring[i] = (flags & MonsterActionFlags.Ring) != 0;
streak[i] = (flags & MonsterActionFlags.Streak) != 0;
weakening[i] = (flags & MonsterActionFlags.WeakeningCurse) != 0;
festering[i] = (flags & MonsterActionFlags.FesteringCurse) != 0;
corruption[i] = (flags & MonsterActionFlags.Corruption) != 0;
destructive[i] = (flags & MonsterActionFlags.DestructiveCurse) != 0;
corrosion[i] = (flags & MonsterActionFlags.Corrosion) != 0;
names[i] = rule.Expression;
priorities[i] = actions.BoundedPriority.ToString(CultureInfo.InvariantCulture);
damage[i] = DamageTypeDisplay(actions.DamageType);
extraVuln[i] = DamageTypeDisplay(actions.ExtraVulnerability);
weapon[i] = ItemDisplayName(actions.WeaponObjectId, actions.WeaponName);
offhand[i] = ItemDisplayName(actions.OffhandObjectId, actions.OffhandName);
petDamage[i] = DamageTypeDisplay(actions.PetDamageType);
moveUpIcons[i] = 0x060028FCu;
moveDownIcons[i] = 0x060028FDu;
}
_monsterFesterColumn = fester;
_monsterBroadsideColumn = broadside;
_monsterGravityWellColumn = gravityWell;
_monsterImperilColumn = imperil;
_monsterYieldColumn = yield;
_monsterVulnerabilityColumn = vulnerability;
_monsterAttackColumn = attack;
_monsterRingColumn = ring;
_monsterStreakColumn = streak;
_monsterWeakeningColumn = weakening;
_monsterFesteringColumn = festering;
_monsterCorruptionColumn = corruption;
_monsterDestructiveColumn = destructive;
_monsterCorrosionColumn = corrosion;
_monsterNameColumn = names;
_monsterPriorityColumn = priorities;
_monsterDamageColumn = damage;
_monsterExtraVulnColumn = extraVuln;
_monsterWeaponColumn = weapon;
_monsterOffhandColumn = offhand;
_monsterPetDamageColumn = petDamage;
_monsterMoveUpIconsColumn = moveUpIcons;
_monsterMoveDownIconsColumn = moveDownIcons;
}
private void ToggleMonsterFlagAt(int row, MonsterActionFlags flag) =>
UpdateMonsterActionsAt(row, actions => actions with { Flags = actions.Flags ^ flag });
@ -2515,6 +2634,7 @@ internal sealed partial class MossTankPanel
var rule = new MonsterRule(expression, new MonsterRuleActions());
_combatSettings.Rules.Add(rule);
_monsterEditorNotice = $"Added {expression}.";
RefreshMonsterEditor();
SaveProfile();
}
catch (FormatException error)
@ -2575,6 +2695,7 @@ internal sealed partial class MossTankPanel
_combatSettings.Rules.RemoveAt(row);
_monsterEditorNotice = $"Removed {rule.Expression}.";
EnsureDefaultMonsterRule();
RefreshMonsterEditor();
SaveProfile();
}
@ -2690,6 +2811,7 @@ internal sealed partial class MossTankPanel
_combatSettings.Rules.RemoveAt(row);
_combatSettings.Rules.Insert(destination, current);
_monsterEditorNotice = $"Moved {current.Expression}.";
RefreshMonsterEditor();
SaveProfile();
}
@ -2700,6 +2822,7 @@ internal sealed partial class MossTankPanel
return;
MonsterRule current = _combatSettings.Rules[row];
_combatSettings.Rules[row] = new MonsterRule(current.Expression, update(current.Actions));
RefreshMonsterEditor();
SaveProfile();
}
@ -2807,9 +2930,34 @@ internal sealed partial class MossTankPanel
private void RefreshMetaEditor()
{
_metaRows = _metaProfile.Rules.Select(static rule =>
$"{rule.State,-16} {DescribeMetaCondition(rule.Condition),-34} "
+ DescribeMetaAction(rule.Action)).ToArray();
int count = _metaProfile.Rules.Count;
var rows = new string[count];
var deleteColumn = new string[count];
var moveUpIcons = new uint[count];
var moveDownIcons = new uint[count];
var stateColumn = new string[count];
var conditionColumn = new string[count];
var actionColumn = new string[count];
for (int i = 0; i < count; i++)
{
MetaRule rule = _metaProfile.Rules[i];
string conditionText = DescribeMetaCondition(rule.Condition);
string actionText = DescribeMetaAction(rule.Action);
rows[i] = $"{rule.State,-16} {conditionText,-34} {actionText}";
deleteColumn[i] = "X";
moveUpIcons[i] = 0x060028FCu;
moveDownIcons[i] = 0x060028FDu;
stateColumn[i] = rule.State;
conditionColumn[i] = conditionText;
actionColumn[i] = actionText;
}
_metaRows = rows;
_metaDeleteColumn = deleteColumn;
_metaMoveUpIconsColumn = moveUpIcons;
_metaMoveDownIconsColumn = moveDownIcons;
_metaStateColumn = stateColumn;
_metaConditionColumn = conditionColumn;
_metaActionColumn = actionColumn;
_selectedMetaRule = ClampRow(_selectedMetaRule, _metaProfile.Rules.Count);
if (SelectedMetaRule is not MetaRule selected)
return;
@ -4111,6 +4259,7 @@ internal sealed partial class MossTankPanel
_coverageSpellSnapshot = null;
_coverageRefreshRemaining = 0d;
EnsureDefaultMonsterRule();
RefreshMonsterEditor();
RefreshItemEditors();
RefreshLootEditor();
RefreshRouteEditor();

View file

@ -1002,6 +1002,34 @@ public sealed class MossTankPanelTests
Assert.Empty(panel.ExcludedComponentRows);
}
[Fact]
public void ExcludedComponentIconsDoesNotScanLiveInventoryOnEveryRead()
{
// Fix round B item 12: ExcludedComponentIcons used to call
// ResolveComponentIcon (CaptureOwnedItems(), a live inventory scan)
// once per row on every retained-UI draw. It must now be a plain
// field materialized once in RefreshItemEditors.
var automation = new FakeAutomation
{
ItemEntries =
[
Item(20, "Charged Yellow Scarab", 0x20) with { IconId = 0x06001234u },
],
};
var host = new FakeHost(automation);
var panel = new MossTankPanel(host);
host.Selection.Select(20);
panel.AddSelectedComponent();
Assert.Equal(0x06001234u, panel.ExcludedComponentIcons[0]);
int callsAfterAdd = automation.CaptureOwnedItemsCallCount;
// Simulate several retained-UI draw frames reading the same column.
for (int i = 0; i < 5; i++)
_ = panel.ExcludedComponentIcons;
Assert.Equal(callsAfterAdd, automation.CaptureOwnedItemsCallCount);
}
[Fact]
public void BuffPickerAddsToTheRequestedListAndAnyCellClickDeletes()
{
@ -2087,6 +2115,43 @@ public sealed class MossTankPanelTests
Assert.Equal("Fire Sword", second.MonsterWeaponColumn[0]);
}
[Fact]
public void MonsterGridColumnsDoNotScanLiveInventoryOrAllocateOnEveryRead()
{
// Fix round B item 12: every Monsters-grid column getter used to
// re-run a LINQ .Select(...).ToArray() over _combatSettings.Rules
// on every retained-UI draw; Weapon/Offhand additionally called
// ItemDisplayName (CaptureOwnedItems(), a live inventory scan) once
// per row per frame. All 23 columns are now materialized once in
// RefreshMonsterEditor: repeated reads must not re-scan inventory,
// and must return the SAME array instance (proof of no fresh
// allocation per read).
var automation = new FakeAutomation
{
Name = "Perf Check",
ItemEntries = [Item(10, "Fire Sword", 1)],
};
var host = new FakeHost(automation);
var panel = new MossTankPanel(host);
host.Selection.Select(10);
panel.AddSelectedItem();
panel.CycleMonsterWeaponAt(0); // populates the Weapon column with a real name
int callsAfterCycle = automation.CaptureOwnedItemsCallCount;
IReadOnlyList<string> weaponFirstRead = panel.MonsterWeaponColumn;
for (int i = 0; i < 5; i++)
{
_ = panel.MonsterFesterColumn;
_ = panel.MonsterNameColumn;
_ = panel.MonsterWeaponColumn;
_ = panel.MonsterOffhandColumn;
_ = panel.MonsterMoveUpIcons;
}
Assert.Equal(callsAfterCycle, automation.CaptureOwnedItemsCallCount);
Assert.Same(weaponFirstRead, panel.MonsterWeaponColumn);
}
[Fact]
public void RouteGridDeletesByAnyCellClickAndSelectsNearestByDistance()
{
@ -2230,6 +2295,33 @@ public sealed class MossTankPanelTests
Assert.Equal(["Hunt"], panel.MetaStateColumn);
}
[Fact]
public void MetaAndRouteGridColumnsDoNotReallocateOnEveryRead()
{
// Fix round B item 12: MetaStateColumn/MetaConditionColumn/
// MetaActionColumn/MetaDeleteColumn/MetaMoveUpIcons/
// MetaMoveDownIcons and RouteWaypointCountColumn used to rebuild a
// fresh array with LINQ on every retained-UI draw. All are now
// materialized once in RefreshMetaEditor/RefreshRouteEditor:
// repeated reads must return the SAME array instance.
var panel = new MossTankPanel(new FakeHost(new FakeAutomation
{
NavigationSnapshot = NavigationAt(0f),
}));
panel.SelectMetaAction(nameof(MetaActionKind.ChatCommand));
panel.SetMetaActionTextDraft("/mt one");
panel.AddMetaRule();
panel.AddRoutePoint();
Assert.Same(panel.MetaStateColumn, panel.MetaStateColumn);
Assert.Same(panel.MetaConditionColumn, panel.MetaConditionColumn);
Assert.Same(panel.MetaActionColumn, panel.MetaActionColumn);
Assert.Same(panel.MetaDeleteColumn, panel.MetaDeleteColumn);
Assert.Same(panel.MetaMoveUpIcons, panel.MetaMoveUpIcons);
Assert.Same(panel.MetaMoveDownIcons, panel.MetaMoveDownIcons);
Assert.Same(panel.RouteWaypointCountColumn, panel.RouteWaypointCountColumn);
}
[Fact]
public void MetaEditorPopupOpensOnCellClickOrCreateAndClosesOnApplyOrCancel()
{
@ -2992,7 +3084,15 @@ public sealed class MossTankPanelTests
public int BusyReferences { get; set; }
public PluginNavigationSnapshot NavigationSnapshot { get; set; }
public PluginNavigationSnapshot Snapshot => NavigationSnapshot;
public IReadOnlyList<PluginInventoryItem> CaptureOwnedItems() => ItemEntries;
// Fix round B item 12: counts CaptureOwnedItems calls so a test can
// prove a column getter is a plain field read (no live inventory
// scan) rather than per-row/per-frame work.
public int CaptureOwnedItemsCallCount { get; private set; }
public IReadOnlyList<PluginInventoryItem> CaptureOwnedItems()
{
CaptureOwnedItemsCallCount++;
return ItemEntries;
}
public IReadOnlyList<PluginWorldObject> CaptureObjects() => WorldObjects;
bool IWorldObjectAutomation.TryGet(