Skip to content

Performance + memory-leak review (2026-06-12)

Scope: AbioticEditor.Core + AbioticEditor.App view-models. Fixes applied everywhere except the concurrently-edited files (MainPage.xaml, MainPage.xaml.cs, MainViewModel.cs) - findings there are recommendations only. Suite green after the changes: 296/296 (dotnet test tests/AbioticEditor.Tests).

Findings

#IssueSeverityStatusWhere
1WorldLevelIndex.TryReadLevelGuid read entire world saves (15 MB+) via File.ReadAllBytes for every WorldSave_*.sav in the folder scan -> repeated LOH allocations on every player-save load (respawn level picker).HighFixed - streams the file in 256 KB chunks with a carried overlap window; same match semantics (needle + 32-hex run + NUL), SIMD Span.IndexOf.Core/WorldSaves/WorldLevelIndex.cs
2ApplyFlagFilter rebuilt every FlagItemViewModel on every keystroke of the filter box; with SHOW INACTIVE on it also recomputed MissingPrerequisitesFor per catalog flag, where each prereq check was a linear scan of the active-flag ObservableCollection -> O(flags × prereqs × activeFlags) per keystroke.HighFixed - item VMs cached in _flagItemCache and rebuilt only when the flag set (or the inactive toggle / async facility flags) changes; keystrokes only filter+group. HasWorldFlag now hits a synced HashSet (O(1)).App/ViewModels/WorldEditorViewModel.cs
3StoryProgressionCatalog.IndexOf allocated a new List per call (Chapters.ToList().FindIndex); ChapterForFlag/Find were linear scans. All three sit on per-flag hot paths (flag rebuilds, grouping, FlagItemViewModel ctor).MediumFixed - precomputed RowIndex and ChapterByTriggerFlag dictionaries (OrdinalIgnoreCase), no per-call allocation.Core/WorldSaves/StoryProgressionCatalog.cs
4FlagGate.PrerequisitesFor / RegionChapterFor re-derived results (string parsing + chapter scans + list allocs) for the same flags on every rebuild.MediumFixed - memoized in ConcurrentDictionary caches; key space bounded by the flag vocabulary (~109 known flags + save-specific ones).Core/WorldSaves/FlagGate.cs
5Bulk flag mutations (UNLOCK STORY THROUGH HERE ≈ 37 adds, trader unlock, prereq fill, revert) fired Flags.CollectionChanged -> full list rebuild per added flag (O(n²) rebuild churn), plus per-add story-timeline notifications.MediumFixed - RunFlagBatch suppresses per-item refresh and does one rebuild + one timeline notification at the end.App/ViewModels/WorldEditorViewModel.cs
6Single-flag operations (ToggleFlag/AddFlag/RemoveFlag) rebuilt the visible list twice (once via CollectionChanged, once via redundant explicit ApplyFlagFilter()+Refresh()).LowFixed - the CollectionChanged handler is the sole refresher.App/ViewModels/WorldEditorViewModel.cs
7ToggleFlagCommand, RemoveFlagCommand, ClearCategoryFilterCommand returned a fresh RelayCommand on every getter access (allocation per binding evaluation; orphans CanExecuteChanged - the documented project gotcha).LowFixed - stored instances via ??=.App/ViewModels/WorldEditorViewModel.cs
8RecipeListViewModel.ApplyFilter spawned one Task.Run per visible row for icon extraction - the first unfiltered pass queues ~600 tasks (thread-pool flood); also constructed for every world editor via GlobalRecipeBrowser.MediumFixed - unrequested rows are claimed (TryClaimIconRequest) and extracted on one background task per filter pass; EnsureIcon kept for single-row use (detail pane). Repeat passes are no-ops per row.App/ViewModels/RecipeListViewModel.cs
9PlayerEditorViewModel.BuildSkillList extracted skill-icon textures synchronously during editor construction (UI thread; first run per machine decodes 15+ textures).MediumFixed - VMs build with null icons, one background task extracts and assigns; SkillViewModel.IconPath now raises PropertyChanged so icons pop in.App/ViewModels/PlayerEditorViewModel.cs, SkillViewModel.cs
10Hot-path EditorLog.Info calls interpolated the log message even when logging is disabled - slider drags (vitals, durability, XP) allocate a string per tick.LowFixed - guarded with EditorLog.Enabled in InventorySlotViewModel.Set, PlayerEditorViewModel.Set, SkillViewModel.Xp.App/ViewModels/*
11MainViewModel._benchCache / _worldFlagCache are static dictionaries keyed by facility path that grow unbounded across folders/sessions and pin entire WorldDeployable lists (thousands of records per world) for the process lifetime.MediumRecommended (constrained file) - see below.App/ViewModels/MainViewModel.cs
12ProgressContext.WorldFlags (static) keeps the previous world's flags after switching folders - the codex/recipe gates can then judge a player against the wrong world until a facility save is re-parsed.LowRecommended (constrained file) - clear it in LoadFolderAsync.App/Services/ProgressContext.cs + MainViewModel.cs
13PlayerEditor/WorldEditor setters unsubscribe OnEditorContextChanged before Set; re-assigning the same instance would silently drop the subscription (Set returns false -> no resubscribe). Currently unreachable (loads always null first), but fragile.LowRecommended (constrained file) - unsubscribe inside the if (Set(...)) branch using the captured old value.App/ViewModels/MainViewModel.cs

Verified non-issues (audited, no action)

AreaVerdict
Event-handler leaks across editorsNone found. Slot/skill/door/NPC PropertyChanged subscriptions are intra-editor (editor subscribes to objects it owns); editor + children become garbage together on switch. MainViewModel correctly detaches OnEditorContextChanged from outgoing editors. TraderCardViewModel's _worldHasFlag closure captures only its own editor. StoryTimeline/ctor Flags.CollectionChanged lambdas are self-subscriptions.
ProgressContext.NotifyHolds a closure over the shell VM - an app-lifetime singleton, so no growth. (It does keep the last status message path alive; harmless.)
Texture cache growthExtractTextureAsPng checks the on-disk cache (File.Exists) before any decode - no re-extraction; cache size is bounded by the set of distinct game assets actually viewed. IconColorizer likewise caches per (mask, tint suffix). No unbounded in-memory texture cache exists.
GameDataServicesLoad-once catalogs + reverse lookups; bounded by game data; dictionary-backed Find (no scans).
InventorySlotViewModel._specialChainsStatic but computed once after the catalog loads; a handful of small arrays.
EditorLog.SeenUnknownDeduped per (area|key), cleared on every folder load (ResetUnknownDedup); bounded. Log files pruned to 7.
UI virtualization (report-only - XAML constrained)All large lists already use CollectionView (flag groups, doors, dropped, containers, recipes, codex, traders, bases, chapters). BindableLayout is used only for small, fixed-size collections: skills (15), milestone chips, traits, transmog toggles (12), appearance fields (13), narrative NPCs (~a dozen), per-base benches, category chip rows. Dropped items are capped at 200 rows, the item palette at 72 - fine as-is.

Allocation sanity (reasoned)

Worst pre-fix path: flag filter keystroke with SHOW INACTIVE on ≈ 109 inactive + ~100 active FlagItemViewModel constructions (each doing a linear 37-chapter scan + flag parse), plus ~109 × PrerequisitesFor (each allocating a per-call chapter list and doing up-to-36 linear ObservableCollection.Contains scans), plus one List alloc per StoryProgressionCatalog.IndexOf call in grouping - several hundred thousand string comparisons and ~1k transient allocations per keystroke. Post-fix the same keystroke is a filter + group over ~200 cached VMs with dictionary lookups. The folder-scan path no longer allocates a 15 MB+ LOH array per world file (a Cascade-style folder has dozens).

Notes

  • UsmapInstallTests.cs (added concurrently by another session) had a compile error (dir local shadowing) that blocked the test suite; renamed the inner local to tempDir. No behavioral change.
  • One transient SchemaDumpTests.DumpWorldFacilityAsJson failure (file lock during a concurrent fixture move) passed on re-run; full suite green at 296/296.

Recommendations for the constrained files (apply when free)

  1. MainViewModel._benchCache / _worldFlagCache - make them instance fields (the shell VM is a singleton anyway) or clear both in LoadFolderAsync; better, keep only the current folder's entry (single-slot cache keyed by facility path). They currently pin every visited world's deployables forever.
  2. ProgressContext.WorldFlags - set to null in LoadFolderAsync so gates can't run against a previous world after switching folders.
  3. Editor setters - move the PropertyChanged -= detach inside the if (Set(...)) branch (capture the old instance first) so a same-instance reassignment can't drop the subscription.
  4. Flag filter box - consider a ~250 ms debounce on the FlagFilter Entry binding (UI-side); with the VM-side caching this is now optional polish.

A fan-made tool. Not affiliated with or endorsed by the developers of Abiotic Factor.