Skip to content

Fix multi-minute freeze when generating anvil interaction help on large modlists#132

Open
Maeyanie wants to merge 2 commits into
jayugg:masterfrom
Maeyanie:fix/anvil-tooltip-freeze
Open

Fix multi-minute freeze when generating anvil interaction help on large modlists#132
Maeyanie wants to merge 2 commits into
jayugg:masterfrom
Maeyanie:fix/anvil-tooltip-freeze

Conversation

@Maeyanie

@Maeyanie Maeyanie commented Jun 11, 2026

Copy link
Copy Markdown

Problem

The first time a player looks at an anvil after launch, BlockAnvil.GetPlacedBlockInteractionHelp builds its workable-item list by calling GetRequiredAnvilTier(stack) for every handbook stack of every IAnvilWorkable item. On a large modlist this froze the game for ~2 minutes per anvil tier (profiled hotspot: GetGridRecipesAsIngredient).

The cost came from three compounding issues in the metal material resolution that the tier check runs through:

  1. The ItemStack overload of GetOrCacheMetalMaterial bypassed the cache. For any collectible whose class doesn''t implement IAnvilWorkable (i.e. every behavior-based workable such as the CastToolHead items matched by ToolHeadSelector), it called the private uncached GetMetalMaterial on every invocation.
  2. Failed resolutions were never cached. CacheHelper.GetOrAdd skips storing null, so items with no resolvable metal material - exactly the ones that hit the expensive fallback, e.g. non-metal items matched by the broad default ToolHeadSelector regex (head|blade|boss|barrel|stirrup|part) - re-ran the full resolution on every single call.
  3. The fallback itself was a full scan: a linear search of all smithing recipes plus an enumeration of all grid recipes x all ingredients, with LINQ allocations, per call.

Fix

  • GetSmithingRecipe, GetSmithingRecipesAsIngredient, and GetGridRecipesAsIngredient now build a one-time reverse index (recipes keyed by output/ingredient collectible code) lazily via ObjectCacheUtil, turning each lookup into a dictionary hit.
  • GetOrCacheMetalMaterial(CollectibleObject) now caches negative results too. A new MaterialsResolved flag on MetalMaterialLoader guards this so lookups that happen before AssetsFinalize can''t poison the cache with premature nulls.
  • The ItemStack overload routes both of its fallback paths through the cached collectible-level method.

CacheHelper.GetOrAdd is left unchanged since other callers rely on its retry-on-null behavior. The indexed lookups return the same recipes the old LINQ queries did (minus exact duplicates of the same recipe, which no caller depended on).

Results

Tested on a large modlist: the first anvil tooltip went from a ~2 minute freeze to a ~200 ms stutter. Behavior is otherwise unchanged.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Fixed metal material caching to prevent storing null values during initial resource loading, which could cause incorrect results later.
  • Improvements

    • Optimized smithing and grid recipe lookups by using precomputed cached indices instead of repeated queries.
    • Enhanced metal material resolution tracking to ensure caching only occurs after all materials are properly resolved.

…dlists

BlockAnvil.GetPlacedBlockInteractionHelp queries GetRequiredAnvilTier for
every handbook stack of every IAnvilWorkable item. With
CollectibleBehaviorCastToolHead attached to everything matching
ToolHeadSelector, each query resolved the metal material from scratch:

- The ItemStack overload of GetOrCacheMetalMaterial bypassed the material
  cache for all behavior-based workables (any collectible whose class is
  not IAnvilWorkable), calling the uncached resolver every time.
- Failed resolutions (null) were never cached, so items with no metal
  material re-ran the full fallback on every call.
- The fallback linearly scanned all smithing recipes and all grid
  recipes x ingredients per call.

Fix: route both fallback paths of the ItemStack overload through the
collectible-level cache; cache negative results once MetalMaterialLoader
has resolved its materials (new MaterialsResolved flag prevents
premature nulls from poisoning the cache); and replace the linear recipe
scans in GetSmithingRecipe, GetSmithingRecipesAsIngredient and
GetGridRecipesAsIngredient with one-time reverse indexes keyed by
collectible code, built lazily via ObjectCacheUtil.

On a large modlist this cuts the first anvil tooltip from ~2 minutes of
freeze to ~200 ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Maeyanie, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 43 minutes and 59 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 01485634-95fa-484d-8e91-2fb29ebc6094

📥 Commits

Reviewing files that changed from the base of the PR and between c454eae and 304db7a.

📒 Files selected for processing (1)
  • SmithingPlus/Util/CollectibleExtensions.cs
📝 Walkthrough

Walkthrough

This PR improves caching reliability and performance across the metal material and recipe lookup pipelines. A new MaterialsResolved signal is introduced to track when material resolution completes, enabling conditional cache writes that avoid poisoning the cache during early loading. Metal material lookups now use this signal, and recipe lookups are optimized by replacing per-call LINQ queries with precomputed cached dictionaries.

Changes

Caching and Resolution Readiness

Layer / File(s) Summary
Material resolution readiness signal
SmithingPlus/Common/Metal/MetalMaterialLoader.cs
MetalMaterialLoader adds MaterialsResolved property and sets it to true in AssetsFinalize after ResolvedMaterials is populated, signaling that material resolution is complete.
Conditional metal material caching
SmithingPlus/Common/Metal/MetalMaterialExtensions.cs
GetOrCacheMetalMaterial now manually manages cache reads/writes and gates null-result caching behind the MaterialsResolved check; ItemStack overload switches to caching methods in non-anvil and fallback paths.
Recipe lookup caching via precomputed dictionaries
SmithingPlus/Util/CollectibleExtensions.cs
GetSmithingRecipe, GetSmithingRecipesAsIngredient, and GetGridRecipesAsIngredient replace per-call LINQ queries with cached dictionary lookups keyed by output or ingredient collectible codes.

Possibly Related PRs

  • jayugg/SmithingPlus#77: Overlaps directly with this PR's metal-material loader and extensions changes, particularly AssetsFinalize and ResolvedMaterials construction.

Poem

🐰 A cache that guards against early poison,
With recipes indexed and ready to summon,
Materials resolved, now safely assured,
No more LINQ on every call — performance restored! ✨

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and specifically addresses the main change: fixing a multi-minute freeze when generating anvil interaction help on large modlists, which is the core performance issue resolved by this PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
SmithingPlus/Util/CollectibleExtensions.cs (1)

102-102: 💤 Low value

Consider adding a clarifying comment for the deduplication logic.

The condition list[^1] != recipe prevents adding the same recipe multiple times when that recipe has repeated ingredients (e.g., a recipe requiring 2 iron ingots). While the logic is correct, a brief comment would improve maintainability.

📝 Example comment
                 if (code == null) continue;
                 if (!dict.TryGetValue(code, out var list)) dict[code] = list = [];
+                // Prevent duplicate entries when a recipe has the same ingredient multiple times
                 if (list.Count == 0 || list[^1] != recipe) list.Add(recipe);

Also applies to: 121-121

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SmithingPlus/Util/CollectibleExtensions.cs` at line 102, Add a brief
clarifying comment explaining the deduplication check "if (list.Count == 0 ||
list[^1] != recipe) list.Add(recipe);" (and the identical check at the other
occurrence) to state that the guard prevents adding the same recipe twice when a
recipe contains repeated ingredients (e.g., requires 2 iron ingots), i.e., it
only deduplicates consecutive identical entries by comparing the last item
(list[^1]) to the current recipe before adding; place the comment immediately
above the conditional so future maintainers understand the intent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@SmithingPlus/Util/CollectibleExtensions.cs`:
- Line 102: Add a brief clarifying comment explaining the deduplication check
"if (list.Count == 0 || list[^1] != recipe) list.Add(recipe);" (and the
identical check at the other occurrence) to state that the guard prevents adding
the same recipe twice when a recipe contains repeated ingredients (e.g.,
requires 2 iron ingots), i.e., it only deduplicates consecutive identical
entries by comparing the last item (list[^1]) to the current recipe before
adding; place the comment immediately above the conditional so future
maintainers understand the intent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 69df2750-0108-4efc-bb30-f59952086fad

📥 Commits

Reviewing files that changed from the base of the PR and between 241b9f2 and c454eae.

📒 Files selected for processing (3)
  • SmithingPlus/Common/Metal/MetalMaterialExtensions.cs
  • SmithingPlus/Common/Metal/MetalMaterialLoader.cs
  • SmithingPlus/Util/CollectibleExtensions.cs

Add comment suggested by coderabbitai.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant