Skip to content

Fix runtime item persistence across save reloads - #138

Merged
ifBars merged 2 commits into
betafrom
agent/runtime-item-reload-persistence
Jul 25, 2026
Merged

Fix runtime item persistence across save reloads#138
ifBars merged 2 commits into
betafrom
agent/runtime-item-reload-persistence

Conversation

@ifBars

@ifBars ifBars commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

  • Retain S1API builder-created runtime item definitions and re-register them at the pre-load lifecycle seam when the native registry has removed them during scene transitions.
  • Preserve explicit ItemManager.UnregisterItem behavior by forgetting retained entries only after native removal succeeds.
  • Accept vanilla representation template IDs in custom-product save descriptors instead of incorrectly requiring a namespaced logical product kind.
  • Bump the prerelease version to 3.1.0-beta.7.

Root cause

The native registry removes runtime definitions when returning to the menu. Mods that cache their registrations then skip rebuilding them on the next load, so save hydration cannot resolve placed buildables or stored custom items.

Validation

  • dotnet test S1API.Tests/S1API.Tests.csproj -c MonoMelon --no-restore --no-build — 307 passed.
  • Focused Mono and IL2CPP tests for the new registry and descriptor validation — 6 passed in each runtime.
  • Real-game smoke: same-process save → menu → reload and fresh-process restart → load, in both Mono and IL2CPP. Each retained the placed tablet press and five stored MDMA crystals.
  • The full IL2CPP test run executed all 200 discovered tests successfully, then the test host hit the existing GameAssembly-absent finalizer crash from uninitialized wrapper test objects.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 67473ecd-40e3-4153-8cd8-4c388700a74b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@ifBars ifBars self-assigned this Jul 25, 2026
@ifBars ifBars added the bug Something isn't working label Jul 25, 2026
@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes runtime item definitions disappearing after a save→menu→reload cycle by introducing RuntimeItemDefinitionRegistry, which caches every definition produced by S1API builders and re-registers any that the native registry has discarded when GameLifecycle.OnPreLoad fires. It also corrects CustomProductSavePersistence.TryValidate to accept vanilla (non-namespaced) product template IDs instead of requiring a namespaced kind ID.

  • RuntimeItemDefinitionRegistry — new internal static class with a Dictionary under a lock, a lazy OnPreLoad hook, and explicit Retain/Forget calls wired into StorableItemDefinitionBuilderBase.Build() and ItemManager.UnregisterItem() respectively.
  • TryValidate fix — replaces the strict ProductKindId.Normalize() call on RepresentationTemplateId with a simple IsNullOrWhiteSpace guard, allowing bare vanilla IDs like "cocaine" to pass.
  • Version bump3.1.0-beta.63.1.0-beta.7 in both S1API.cs and S1API.csproj.

Confidence Score: 4/5

Safe to merge. The retention logic is straightforward, well-tested, and guarded by a snapshot-under-lock pattern that avoids holding the lock during native registry calls.

The core retention mechanism, the Forget-on-unregister wiring, and the descriptor validation fix are all correct. Two gaps worth monitoring: _adapter is read outside the lock in ReapplyAll (harmless in production but imprecise), and the public RegisterItem/EnsureItemRegistered APIs bypass retention entirely with no documentation warning, which could cause confusion for mod authors doing advanced registration outside the builder pattern.

Files Needing Attention: S1API/Items/ItemManager.cs — RegisterItem and EnsureItemRegistered do not call Retain, so items registered through those paths will still vanish after a reload. S1API/Internal/Items/RuntimeItemDefinitionRegistry.cs — _adapter field lacks volatile.

Important Files Changed

Filename Overview
S1API/Internal/Items/RuntimeItemDefinitionRegistry.cs New class that retains builder-created item definitions and re-registers them at OnPreLoad after native registry purges them during scene transitions. Logic is correct; minor: _adapter is read outside the lock in ReapplyAll().
S1API/Items/ItemManager.cs UnregisterItem now calls RuntimeItemDefinitionRegistry.Forget() conditioned on confirmed native removal. Public RegisterItem and EnsureItemRegistered APIs do not call Retain, creating a silent persistence gap for items registered outside the builder.
S1API/Items/Storable/StorableItemDefinitionBuilder.cs Build() now calls RuntimeItemDefinitionRegistry.Retain() immediately after AddToRegistry(). All subclass builders delegate to base.Build(), so they all inherit the retention behavior.
S1API/Internal/Products/CustomProductSavePersistence.cs TryValidate now accepts vanilla (non-namespaced) RepresentationTemplateIds by replacing the strict ProductKindId.Normalize() call with a simple IsNullOrWhiteSpace guard. Change is correct and widens allowed input appropriately.
S1API.Tests/Items/RuntimeItemDefinitionRegistryTests.cs New unit tests cover the three key retention paths: re-apply when absent, skip when already present, and honour Forget(). RecordingAdapter correctly isolates native registry calls.
S1API.Tests/Products/CustomProductSavePersistenceTests.cs Tests confirm vanilla template IDs pass and empty/whitespace IDs are rejected. Uses reflection to invoke the private TryValidate method directly.

Sequence Diagram

sequenceDiagram
    participant Mod
    participant Builder as StorableItemDefinitionBuilder
    participant Registry as S1Registry (native)
    participant Retain as RuntimeItemDefinitionRegistry
    participant Lifecycle as GameLifecycle.OnPreLoad

    Mod->>Builder: .Build()
    Builder->>Registry: AddToRegistry(definition)
    Builder->>Retain: Retain(id, definition)
    Retain->>Lifecycle: subscribe ReapplyAll (once)

    Note over Registry: Scene transition → native registry clears runtime items

    Lifecycle-->>Retain: OnPreLoad fires
    loop each retained entry
        Retain->>Registry: IsRegistered(id)?
        alt not present
            Retain->>Registry: AddToRegistry(definition)
        end
    end

    Note over Mod: Mod calls UnregisterItem(id)
    Mod->>Registry: RemoveFromRegistry(definition)
    Registry-->>Mod: "removed = true"
    Mod->>Retain: Forget(id)
Loading

Comments Outside Diff (1)

  1. S1API/Items/ItemManager.cs, line 133-141 (link)

    P2 RegisterItem and EnsureItemRegistered silently skip retention

    Both public registration APIs call S1Registry.Instance.AddToRegistry(...) directly without calling RuntimeItemDefinitionRegistry.Retain(), so items registered through either path are not reapplied after a scene transition. The XML docs for RegisterItem describe it as appropriate for "advanced scenarios" but give no hint that it opts out of cross-load persistence. A mod developer who reaches for RegisterItem to register a dynamically-created item — exactly the case this PR targets — will encounter the same disappearing-item bug after a menu→reload cycle.

    If the omission is intentional (e.g. these APIs are only expected to be called from within OnPreLoad itself), a brief note in the doc comment would prevent the confusion.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: S1API/Items/ItemManager.cs
    Line: 133-141
    
    Comment:
    **`RegisterItem` and `EnsureItemRegistered` silently skip retention**
    
    Both public registration APIs call `S1Registry.Instance.AddToRegistry(...)` directly without calling `RuntimeItemDefinitionRegistry.Retain()`, so items registered through either path are not reapplied after a scene transition. The XML docs for `RegisterItem` describe it as appropriate for "advanced scenarios" but give no hint that it opts out of cross-load persistence. A mod developer who reaches for `RegisterItem` to register a dynamically-created item — exactly the case this PR targets — will encounter the same disappearing-item bug after a menu→reload cycle.
    
    If the omission is intentional (e.g. these APIs are only expected to be called from within `OnPreLoad` itself), a brief note in the doc comment would prevent the confusion.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Codex Fix in Claude Code Fix in Cursor

Fix All in Codex Fix All in Claude Code Fix All in Cursor

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
S1API/Internal/Items/RuntimeItemDefinitionRegistry.cs:57-59
**`_adapter` read outside the lock in `ReapplyAll`**

`_adapter` is assigned inside `lock(Gate)` in `ResetForTesting` (line 134) but is read at line 110 in `ReapplyAll` with no lock held. Without a `volatile` annotation or a read barrier, the JIT is free to hoist the field load out of the loop, so a concurrent test that swaps the adapter mid-iteration could observe the old value indefinitely. In production this is a non-issue since the field never changes after initialization, but making the field `volatile` would make the invariant explicit and keep the testing seam safe under any scheduler.

### Issue 2 of 2
S1API/Items/ItemManager.cs:133-141
**`RegisterItem` and `EnsureItemRegistered` silently skip retention**

Both public registration APIs call `S1Registry.Instance.AddToRegistry(...)` directly without calling `RuntimeItemDefinitionRegistry.Retain()`, so items registered through either path are not reapplied after a scene transition. The XML docs for `RegisterItem` describe it as appropriate for "advanced scenarios" but give no hint that it opts out of cross-load persistence. A mod developer who reaches for `RegisterItem` to register a dynamically-created item — exactly the case this PR targets — will encounter the same disappearing-item bug after a menu→reload cycle.

If the omission is intentional (e.g. these APIs are only expected to be called from within `OnPreLoad` itself), a brief note in the doc comment would prevent the confusion.

Reviews (1): Last reviewed commit: "chore(release): bump version to 3.1.0-be..." | Re-trigger Greptile

Comment on lines +57 to +59
private static IRuntimeItemDefinitionRegistryAdapter _adapter =
RuntimeItemDefinitionRegistryAdapter.Instance;
private static bool _hooked;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 _adapter read outside the lock in ReapplyAll

_adapter is assigned inside lock(Gate) in ResetForTesting (line 134) but is read at line 110 in ReapplyAll with no lock held. Without a volatile annotation or a read barrier, the JIT is free to hoist the field load out of the loop, so a concurrent test that swaps the adapter mid-iteration could observe the old value indefinitely. In production this is a non-issue since the field never changes after initialization, but making the field volatile would make the invariant explicit and keep the testing seam safe under any scheduler.

Prompt To Fix With AI
This is a comment left during a code review.
Path: S1API/Internal/Items/RuntimeItemDefinitionRegistry.cs
Line: 57-59

Comment:
**`_adapter` read outside the lock in `ReapplyAll`**

`_adapter` is assigned inside `lock(Gate)` in `ResetForTesting` (line 134) but is read at line 110 in `ReapplyAll` with no lock held. Without a `volatile` annotation or a read barrier, the JIT is free to hoist the field load out of the loop, so a concurrent test that swaps the adapter mid-iteration could observe the old value indefinitely. In production this is a non-issue since the field never changes after initialization, but making the field `volatile` would make the invariant explicit and keep the testing seam safe under any scheduler.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex Fix in Claude Code Fix in Cursor

@ifBars
ifBars merged commit 0af7710 into beta Jul 25, 2026
5 checks passed
@ifBars
ifBars deleted the agent/runtime-item-reload-persistence branch July 25, 2026 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant