Skip to content

Changelog

All notable changes to RevFramework are documented here.

One changelog, every package. RevFramework ships as three system packages — Inventory, Pickups & Crafting · Health & Status Effects · Currency & Economy — plus Complete. All are built from one codebase and share one version number, so they are released together and this file covers all of them.

Entries name the system they affect. If an entry names a system your package doesn't include, it doesn't apply to you and nothing is missing from your install. Entries that name no system — Unity version compatibility, editor windows, shared infrastructure — apply to every package.

To check what you have installed: RevFrameworkVersion.Current from code, or the version shown at the bottom of your welcome window — Tools ▸ RevGaming ▸ RevFramework ▸ Help ▸ Show … Welcome.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

Fixed

  • Currency — a service that was disabled and re-enabled stopped being the registered one. SceneCurrencyService released its singleton pointer in OnDisable and only ever claimed it in Awake, which does not run again on re-enable. The service kept working, but nothing held a reference to it — so the duplicate-service check saw a vacancy, and a second service added later ran alongside the first with its own separate wallet store. Balances then landed in whichever one a caller happened to resolve. The pointer is now re-claimed on enable when it is vacant; an incumbent still keeps it, so re-enabling can never destroy another service's GameObject.

  • Core — restoring a save in the wrong participant order silently destroyed crafted output, and getting the order right was left to you. Crafting reconciles offline progress while it restores: a job whose timer elapsed while the game was shut delivers its outputs into the inventory and refunds currency when that delivery fails. Inventory's restore clears the container before applying its snapshot and Currency's writes absolute balances, so either one running after Crafting erased what Crafting had just produced. Nothing reported it — no participant failed, and the report said the load succeeded.

The requirement now lives on the participant that has it instead of in a documentation note. CraftingSaveParticipant implements the new IRevSaveOrdered and declares RevSaveOrder.Late, and RevSaveCoordinator.Restore moves it past the others whatever order you pass.

  • Inventory — item metadata could be rewritten from outside the container that held it. ItemStack is a struct, but its meta is a List reference, so a plain assignment copies the value fields and shares the metadata. Three paths stored a caller's stack directly — InventoryContainer.SetStackAtResult, EquipmentContainer.SetSlotExact and EquipmentContainer.TryEquipStrict — which left the caller holding a live handle into the container. Editing their own copy afterwards rewrote the stored item's enchantments or durability with no OnChanged raised and nothing observing the change. InventoryContainer.PeekStackAt leaked the same handle in the other direction, so editing what you read wrote back into the slot.

All four now clone. This costs nothing in the common case — a stack with no metadata clones its null list as null — so only stacks that actually carry metadata allocate. Most write paths already cloned via WithQuantityDeep; these were the ones that did not.

IReadOnlyInventoryContainer.Slots deliberately still shares, and is now documented as doing so. It is the live backing list, and materialising a defensive copy on every access would allocate on every UI redraw. Read it; use Peek for anything you intend to keep or edit. A test pins that trade so it stays a decision rather than becoming an oversight.

Changed

  • Health — SetMaxHealthClampNoDeathEvents and SetMaxHealthSilent were two copies of the same four statements, and now share one. Behaviour is unchanged and both remain public. The duplication mattered because MaxHealthModifierStack routes to both from a serialized ClampMode enum, so a fix applied to one would silently not apply to the other.

Their documentation was also wrong, in the way most likely to cost you an afternoon. The name ClampNoDeathEvents reads as "suppresses death events, still reports the max change", and the ClampMode member described it as differing from Silent. It does not differ and never has — both emit nothing at all, max-change included. ClampMode.Silent and ClampMode.ClampNoDeathEvents are the same behaviour; only ClampMode.WithEvents emits anything. Both enum members are kept because the enum is serialized into scenes and prefabs, and dropping one would silently reassign every field authored to it. Prefer Silent in new setups.

  • Currency — Money now documents that it cannot be serialized, because its [Serializable] attribute suggests otherwise. amount is readonly, and both Unity's serializer and JsonUtility skip readonly fields, so a Money written out carries no value and reads back as Money.Zero. Persist money.amount as a long and rebuild with new Money(saved) — which is what WalletSnapshotLine and CurrencyJsonSave already do. The attribute is deliberately left in place: removing it would change how any existing [SerializeField] Money in a project behaves.

Deprecated

  • Core — IEventBus is obsolete and will be removed in 2.0. It shipped as a public contract with no implementation behind it and no consumer in front of it: nothing in the framework publishes to, subscribes through, accepts, or returns one, so implementing it never had any effect. The unreachable internal InMemoryEventBus that backed nothing has been removed outright.

This framework raises events per component and guards each one locally — HealthSystem.Died, SceneCurrencyService.OnWalletChanged, CraftingService.OnJobCompleted, LootService.Granted. Subscribe to the system you care about, or supply a bus of your own; nothing here will conflict with it.

  • Docs — IPrefabSaver removed from the Core and UnityIntegration READMEs. No such type has ever existed in the framework.

Added

  • Core — IRevSaveOrdered, an opt-in way for a save participant to declare where it must restore. Most participants should not implement it: restoring is normally independent per section. Implement it only when your restore reads or writes another system's state, which is the only case where order changes the outcome. RevSaveOrder supplies Early, Default and Late so an order reads as intent rather than as a magic number.

A list you ordered deliberately still comes back untouched. The sort is stable and everything that does not implement the interface shares RevSaveOrder.Default, so only a participant that explicitly asks to move, moves. A RestoreOrder getter that throws is reported and defaults to its supplied position rather than costing you the section — a broken ordering hint is no reason to drop state that would otherwise load.

Capture deliberately ignores it. Each participant snapshots state it already owns into its own section, so no capture order produces a different file.

Internal

No runtime behaviour changes. Recorded because both items change what the release gates catch.

  • RevFramework.Core.Abstractions is now under the public-API snapshot gate. It should arguably have been the first assembly enrolled: it is the root of the dependency graph, referencing nothing and referenced by everything, and it holds the contracts the rest of the framework is written against — IDamageable, ITimeProvider, IRandomProvider, ICooldownService, IObjectFinder. Almost every type in it is an interface, so almost every change to it breaks an implementer rather than a caller, which is the expensive kind and the kind a customer hits at compile time. Being unguarded is how a public IEventBus with no implementation and no consumer sat in the shipped surface with no gate remarking on it.

RevFramework.Common, RevFramework.Common.UX and RevFramework.Loot remain unenrolled.

  • The snapshot renderer now records [Obsolete] on a type, not only on its members. Deprecating a whole type produced no diff, even though the golden header calls deprecation contract and every member already carried it — so the loudest signal the gate exists to catch, that a customer's code is about to stop compiling, was the one form of it that slipped through silently.

It found one immediately: Economy.Diagnostics.IItemStoreDebug has been obsolete at the type level in shipped code, and the golden recorded it as live. Its property's deprecation was captured; the interface's was dropped. That golden is corrected here. No API changed — the snapshot simply now says what the source has said all along.

[1.1.0] — 2026-08-03

Added

  • Core — a save coordinator, so one save file can hold state from every system. Each system has its own snapshot API and no two of them share a shape, so combining them has always been the caller's problem. RevSaveCoordinator composes any number of IRevSaveParticipants into a single versioned payload and routes a saved payload back to them.

Implement IRevSaveParticipant for your own game state too — quest flags, unlocked levels, settings. Framework systems are not privileged here; they are just participants, and yours sit in the same save file alongside them.

Behaviours worth knowing: - One scene scan per save, not one per participant. Resolving owners means a FindObjectsByType walk of the loaded scenes, and every participant did its own — four walks for a capture, six for a restore, all returning the same objects, on a path a game may run from an autosave timer. Each operation now shares one. Your own participants get this too, as long as they resolve owners through StableIdOwners, and you can batch your own run of lookups the same way with StableIdOwners.Batch(() => { … }) — prefer that to the BeginScope() form it wraps, because a scope has to be disposed and forgetting is silent. The trade: the scan is a snapshot, so an object spawned partway through a save is not seen by the participants after it — call StableIdOwners.Invalidate() if yours spawns something. Outside a scope nothing is cached and every call scans, as it always did. - Restore applies sections in the order you supply the participants, not the order they sit in the file. Where two participants touch the same state, reordering your list is what decides the outcome — and it has to be your list, because the order inside a save was fixed by whichever build wrote it, from a participant list that may no longer exist. - No file I/O. It takes and returns a string, so where a save lives is yours — Application.persistentDataPath, PlayerPrefs, cloud save, inside a larger save file of your own, or encrypted. Nothing here holds an opinion about platforms. - A participant that throws cannot break the operation. Failures are isolated and reported per section, so a save with one bad section still restores the rest and tells you which one failed. That covers the Key and Version properties as well as the methods — a key read from an unassigned config asset throws from the getter, and a participant that cannot name itself is recorded as failed like any other. Corrupt or truncated payloads report an error rather than throwing — a load that throws is how a game ends up stuck on its main menu. - Data for systems you do not have is preserved, not discarded. Loading a save containing a section no participant claims reports it as unrecognised and hands it back; pass those into the next capture and they survive. Without that, saving on a build with a system removed silently destroys that system's data. A live participant displaces a carried section only when it actually wrote one: a participant that threw, or that had nothing to save — Inventory in a scene with no characters, Currency where no owner has a wallet — leaves the key free and the carried data is written, rather than the save ending up with neither. - When a carried section is displaced, you get it back. One section per key means a passenger can lose its key to the live participant that captured under it. That is reported as its own status, Displaced, and the section itself is handed back in report.Displaced — for one carried out of Unapplied that copy is the last in existence, since it is not in the save being written and the save it came from is about to be overwritten. It does not fail the capture, because the save produced is complete and valid; ToString() names the count so a logged summary cannot read as a bare "OK". - A section a participant refuses is handed back too, in report.Unapplied. The case is a version downgrade — a save written by a newer build, loaded by an older one that cannot read it. Keeping it is not the same as preserving it: the participant that refused is still live and still wins its key, so the next save legitimately overwrites it with current state. What changed is that the data survives the load instead of dying inside the failure, and the overwrite is reported rather than silent, so you can warn the player, stash it elsewhere, or leave that participant out of the next capture and carry the newer data forward intact. - A participant that got halfway is reported separately, in report.PartiallyApplied. Four of the five restore owner by owner, so any of them can apply three wallets and refuse the fourth. That section is no longer a description of anything — half of it is live state now — so it is deliberately kept out of Unapplied, whose whole point is being safe to carry into a later save. Carrying a half-applied section would return the owners that did load to what they held before, discarding everything since. You still get the data, on a path that does not offer to reapply it.

**Writing your own participant:** throw an ordinary exception if you applied nothing — which is
why a version guard should run before you touch anything — and the new
**`RevSavePartialRestoreException`** if you mutated something first. That choice is what decides
whether the caller may carry your section into a later save, so it is worth getting right.
  • Save participants for all five systems that hold state. Health, Currency, Inventory, Crafting and Status Effects each get a participant that adapts their existing snapshot API to the coordinator. None of the five systems changed — every participant is a translator in a define-gated assembly, so removing a system still compiles, exactly like the rest of Integrations/.

Every object whose state you want saved needs a StableId component (RevFramework ▸ Core ▸ Stable Id). Owners are identified by that id because a GameObject reference means nothing across sessions; an object without one is not saved and not restored.

StableId.AssignId(string) is what makes a runtime-spawned object saveable, and there was previously no way to do it at all. An object authored into a scene has its id serialized with the scene, so it is the same value every session — that is the case the save system relies on. A spawned instance has none, so a player build generates one in Awake, and it is a different one every launch: unique within the session, and useless afterwards, because nothing next session claims it. Anything saved against it looks saved and can never be matched again. Pass AssignId something your game derives from durable data — a spawn table entry, a room and slot index, a quest id, a server key — before the object's state is captured.

And a validator for the collision that produces, at Tools ▸ RevGaming ▸ RevFramework ▸ Validate ▸ Duplicate Stable Ids. Duplicating a GameObject copies its serialized id, and automatic generation only fills an empty value, so two objects can legitimately end up claiming one identity. The tool finds them all and names them. Duplicates are reported, never auto-corrected — nothing can tell which copy was the original, so regenerating the wrong one would break every save that referenced it. Which one changes is your call.

Two objects sharing an id now resolve the same way every time, and are always reported. A duplicate is a scene-authoring error nothing can repair — no rule can tell which copy was meant, and regenerating the wrong one breaks every save referencing it — but the choice at least holds still. The scan is unordered, so "the first one wins" meant "whichever the scan reached first", and a capture could take one object's state while the restore applied it to the other. The winner is now the object whose hierarchy path and sibling index sort first. The sibling index is not decoration: Unity lets siblings share a name, so a path of names alone can be identical for two different objects — and a row of prefab instances all called "Enemy" under one parent is both a normal thing to have and a common route to a duplicate id. Detection also runs over the whole scan instead of inside each per-type lookup, which is what a "hero" carrying Health and a second "hero" carrying Inventory needed: the type filter ran first, so neither query ever saw the collision and nothing reported it.

A prefab asset is never given an id. Ids are generated from OnValidate, which fires on a prefab asset as soon as anyone selects it or opens it in Prefab Mode — and because generation only fills an empty value, every instance spawned from that prefab then carried the same id and none of them regenerated. One inspection of a prefab was enough to make every copy of an enemy claim a single identity. A prefab is not a save owner in the first place, so the field now stays blank on the asset and each instance fills it in on becoming a scene object. An id already written to a prefab by an earlier version is left alone, since saves may reference it.

Health needs nothing to construct. The others take what only your game can supply: Currency needs the currency ids to save (there is no "every currency this wallet holds" query), Inventory needs your ItemDatabase, Crafting needs your recipes, and Status Effects needs a factory that rebuilds an effect from an id and its saved remaining time. In each case the reason is the same — the thing has no durable identity of its own for the participant to discover.

Three trades worth knowing, each pinned by a test: renaming a recipe asset invalidates saves that reference it (asset name is a recipe's only identity — the job is dropped and reported, the save still loads); restoring a status runs its Apply, so a status that owns state another participant also restores can apply it twice; and StatusContext.SourceDef is not restored, since there is no asset registry to resolve a ScriptableObject reference through.

Put Inventory and Currency before Crafting in your participant list. Crafting is the only participant whose restore writes into another system's state: it reconciles offline progress as it restores, so a job whose timer elapsed while the game was shut delivers its outputs into the inventory and refunds currency when that delivery fails — during the load. Inventory's restore clears the container before applying its snapshot and Currency's writes absolute balances, so either one running afterwards erases what Crafting just produced. Nothing detects it: no participant failed, and the report reads clean. This is the one cross-participant ordering dependency inside the framework.

A section that matched nothing at all is reported rather than counted as a clean load. Some owners missing is ordinary and stays silent — a save outlives the objects it was taken from. Every owner missing is a section that applied to nothing while every other signal said the load worked, and the symptom is a game full of default state with nothing to explain it. Health, Currency, Inventory and Status Effects each check; Crafting reports the equivalent per job. It is recorded as a plain refusal rather than a partial one, so the section stays carryable. The usual causes are the wrong scene loaded and ids that have changed since — which is exactly what a runtime-spawned object without AssignId does.

A wallet the save does not mention no longer keeps its money in silence. The currency-level version of this is closed by capturing what a wallet holds rather than only what was configured, but the same hole exists one level up: a capture skips an owner with no wallet, so an owner paid after the save was written has no entry in it and a restore never reaches them. Transfer 500 to a companion, quickload, and the player's wallet is restored while the companion keeps the 500 — the money now exists twice. A game could not close this for itself, because ICurrencyWalletQuery answers per owner and there is no enumeration to walk. CurrencySaveParticipant now compares the wallets in the scene against the owners the save names, and zeroes the difference — so a load is the whole truth for every wallet in the scene rather than only for the ones the save happened to name. It zeroes what a wallet holds, not the configured list, since zeroing the configured list would create keys in wallets that never had them.

This is the one place a participant clears rather than applying onto existing state, and the exception is deliberate: that rule is right when the alternative is destroying data, and here the alternative is minting it. The new UnsavedWalletPolicy is the opt-out for a game whose wallets legitimately outlive a load — a persistent world, a shop economy outside the player's save, a mid-session restore — and Leave still warns and names the owners, because a preserved balance and one arriving out of nowhere look identical from inside the save system. A service that answers HasWallet but not TryGetHeldCurrencies cannot be zeroed and says so rather than passing over it.

Crafting's restore replaces the live job list, and a capture now writes a section even when nothing is in flight. It is the one participant that does not apply on top of existing state, because adding to the job list would duplicate every job on a second load. That made the two halves disagree: an idle capture wrote nothing, so a restore never called the participant, so quickloading a save taken while idle left the craft you started afterwards running — while quickloading one taken mid-craft replaced everything. Same operation, opposite outcome. Writing the empty section makes an idle save clear, at a cost of a few bytes.

A dropped craft job now reaches the report. CraftingService.RestoreJobs returns void and drops a job whose owner or recipe will not resolve with a log line, so an asset rename used to cost the player an in-flight craft in complete silence with the load reporting success — and the loss is not just the job, since a craft consumes its inputs and its currency when it starts and Crafting does not refund one it drops. Both resolvers belong to the participant, which is what lets it count the drops. A job whose owner has no StableId is also warned about at capture, where the cause is, rather than only on the load where the symptom appears.

Health saves current, max and dead state — not shields, regen timers or rule state. That is the whole of HealthSnapshot, and HealthSystem.RestoreSnapshot has always documented it. A character saved behind a shield comes back with the right health and no shield. If shields matter to your game, restore them through whatever granted them — a status effect, or your own participant.

A health snapshot that cannot be true is refused rather than clamped. RestoreSnapshot takes what it is given and says the caller is responsible for handing it something consistent — and the participant is that caller. It matters because of how a truncated file reads: an entry that lost its snapshot deserialises to all-zero, which clamps to one max hit point and zero current, leaving a character alive on 1 HP with the load reporting success. Entries whose max is zero or less, or whose current health falls outside 0..max, are now reported and skipped instead. Dead is deliberately not policed against Current, since whether an object at zero health is dead belongs to its death rules rather than to the snapshot.

Economy and Pickups have no participant deliberately: Economy orchestrates Currency and Inventory rather than owning state, and Pickups are world objects. See Integrations/Save/README.md for the full guide, including writing participants for your own game state.

  • Currency — a service can now be asked what an owner's wallet holds. New ICurrencyWalletQuery with HasWallet(GameObject) and TryGetHeldCurrencies(GameObject, List<CurrencyId>), implemented by SceneCurrencyService. Purely additive: it is a separate optional capability rather than new members on ICurrencyService, so a project with its own currency service is unaffected and can opt in whenever it likes.

HasWallet exists because GetBalance reports Money.Zero both for a wallet holding nothing and for an owner that has never touched currency — correct for gameplay, and not enough for a save. The save participant uses it to leave scenery, doors and pickups out of the file instead of writing a set of zero balances for every StableId in the scene.

TryGetHeldCurrencies closes a way to lose money. Balances are addressed by id, so every persistence path has had to be handed the list of currencies to save — and naming only some of them was worse than it looked. An unconfigured currency was neither written to the save nor set by a load, so its balance survived a quickload: spend 500 gems on a sword, load an earlier save, and the sword is gone but the gems are back. Nothing failed and nothing logged. CurrencySaveParticipant now captures the configured list plus whatever each wallet actually holds, so a currency you forgot is still saved. The list is still required — a wallet has no key for a currency it has never held, so only the list can write one at zero, and that line is what lets a restore put a wallet back to zero. A service without the capability answers "cannot tell", never "holds nothing", and keeps the previous configured-only behaviour.

What a load still does not do is remove a currency the save never mentioned, in line with the coordinator's rule that participants apply onto existing state rather than clearing it first. A currency first acquired after a save was written therefore survives loading it; zero the wallet yourself first if a load should be the whole truth.

A balance line that cannot be applied costs that line and nothing else. An edited or corrupted save can carry a blank currency id or a negative amount; those lines are dropped and reported rather than passed to Currency, which validates a whole snapshot before applying any of it and would otherwise refuse every other balance in the same wallet. Blankness is judged through CurrencyId, which normalises whitespace to empty, so a line that merely looks non-empty is caught too.

  • Economy — the with-inventory panel can now fail for a real reason, and reports what money did. EconomyWithInventoryPanel already demonstrated rollback, but every failure in it was injected: a wrapper around IValueLedger or IItemStore that reported failure on request. That proves the rollback path runs when a dependency says no. It does not show the system failing the way a player's full backpack makes it fail.

A new Saturate Container Before Execute toggle fills the live container through the real IInventoryService before the operation runs. Preflight passes, the container genuinely fills, and delivery then fails because there is no space — nothing is wrapped. It composes with the existing force-fail toggles, so both kinds of failure can be seen side by side.

Balance deltas are now logged per operation, which is what makes the rollback lesson legible: a Buy that refunds and a Buy that never charged end on the same balance, and only the delta distinguishes money moved and came back from money never moved. Read against the existing container preview, it also shows why the three flows compensate differently by design — Buy refunds, Craft attempts refund and item restore, and Reward keeps money already granted when a later item grant fails.

Availability unchanged: this panel lives in the Economy + Inventory integration and appears in Complete only. The Currency & Economy package ships no Inventory and is unaffected.

  • Onboarding — the welcome window can now open a sample scene. A new Try It Now section lists one button per system in your package, opening that system's quickstart scene directly. Every other button in that window — Documentation, Discord, Test Suite, the video playlists — sends you out of Unity; this is the one that answers "show me it working" without leaving the editor, which is why it sits above Quick Links rather than among them.

The welcome window now sizes itself to its content, so a first-launch window opens without a scrollbar instead of hiding part of itself below the fold. It only ever grows, never shrinks below its designed size, and is capped below the display height — on a short screen or with editor DPI scaling turned up, the scroll view still covers the overflow rather than pushing the Close button somewhere unreachable.

Behaviour worth knowing: unsaved changes prompt to save first, and cancelling that prompt leaves your scene alone rather than discarding work to show a demo. Scenes are located by asset search rather than a hardcoded Assets/RevFramework/ path, so relocating the framework folder does not break the buttons. A scene that is not present — samples are deletable, and each SKU ships only its own systems' scenes — is skipped silently instead of appearing as a dead button, and a window with no scenes available shows no section at all.

  • Inventory — IReadOnlyInventoryContainer.CountOf(itemGuid) extension method. Container views expose slots but no aggregate queries, so every consumer that needed a quantity walked Slots itself — and those hand-rolled counters drifted apart, some trimming the GUID, some comparing case-sensitively, meaning the same container could report different totals depending on who asked. This one shares the container's own comparison, so a count taken through it agrees with the container's authoritative total.

Deliberately an extension method rather than a new interface member: adding a member to a shipped public interface would break every external implementer, and an extension works for all implementations including your own.

Fixed

  • The test download failed to compile if you had removed the Health system. Two files — HealthBrutalTortureTest and HealthStressTestSpawner — were MonoBehaviour scene harnesses rather than NUnit fixtures, and they sat loose in Tests/EditMode/Health/ above the Hostile/ and InternalTruth/ folders that carry the assembly definitions. Every test assembly is gated on its system's REV_*_PRESENT define, so removing a system cleanly drops that system's tests along with it. Nothing gated those two. With Health removed they still compiled, against types that were no longer present, and reported CS0234 and CS0246 errors from a folder you had just imported — making a correct install look broken.

Both are removed. They were development-only tools for exercising the damage pipeline by hand, with no counterpart in the NUnit suites, which are unchanged. This one lands in the test download rather than the framework package — the two are version-matched, so take the matching tests.

Related, and worth knowing if you have trimmed your install: the tests assume the systems you kept are complete. Delete systems, run Cleanup Orphaned Content…, restore only part of it, and the gating cannot help — a define states that a system is installed, not that every folder backing it is still on disk. The symptom is a CS0234 naming a sub-namespace whose parent resolves fine ('Teaching' does not exist in the namespace 'RevGaming.RevFramework.Health'), meaning the system is back but its panels are still in _RevFrameworkTrash. Both tests pages now say so.

  • Seven compiler warnings that only ever appeared in a player build. Nothing was broken by them, but a player build reported seven RevFramework.* warnings — against a release checklist that asks for none — and they were invisible in normal use because importing into a clean project is an Editor compile. Every warned symbol was read only inside #if UNITY_EDITOR, so the declaration was left stranded once that block compiled out:
Warning Where
CS0168 unused ex PickupEffectFactory
CS0219 unused local HealthAuthority, StatusAuthority
CS0414 unused field HealthBarUIConnector, HealthRegenerationHandler, HealthCombatState, HealthSystem

Each symbol now lives under the same condition as its only reader. The three debugLogs fields are [SerializeField], so they no longer exist in player builds — they were only ever read by editor-only logging, they still appear and work in the Inspector, and Unity ignores serialized data for a field that is not there, so a project with one already toggled is unaffected. No public API and no runtime behaviour changes.

  • Pre-Build Clean left three teachable panels behind, and the build then failed on them. Choosing Clean Build at the pre-build prompt — or running Validate ▸ Pre-Build Clean — moves Teaching/ out of the project, which clears REV_TEACHABLES and stops RevFramework.Teaching.UI compiling. Three panels live outside that folder and so survived the move, still referencing types that had just gone:

  • Integrations/CrossSystem/Economy/InventoryIntegration/EconomyWithInventoryPanel.cs

  • Integrations/Currency/InventoryIntegration/Teaching/CurrencyInventoryBackedPanel.cs
  • Samples/Systems/Crafting/DemoHelpers/FakeInventoryCurrencyTeachablePanel.cs

The result was ten compiler errors and a failed build, from the option offered to prepare a build. It affected anyone with Currency + Inventory, Economy + Inventory, or the Crafting samples installed.

The Samples one was not only reachable through the cleaner. The README says Documentation/, Samples/ and Teaching/ may each be deleted independently, so deleting Teaching/ by hand broke it with no tooling involved.

All three now carry REV_TEACHABLES at source, so they compile out with the panels they depend on. Guarded per-file rather than per-assembly on purpose: the Currency/Inventory assembly also holds the item-backed currency adapters, and constraining the whole assembly would have removed those too.

  • …and the same option could still fail the build it was preparing, for a second reason. Gating the panels above is only half of it: they compile out when REV_TEACHABLES clears, and that symbol is derived from Teaching/ being present. Clearing it is a deferred step — it rides an editor tick that a build already underway is not promised — while the folder move is immediate. Whether the player compiler saw the folder gone but the symbol still set was therefore a race, and losing it produced the same ten CS0234/CS0246 errors that the gating was meant to prevent.

It was won on 2026-07-29 and lost on 2026-07-31 with the same three folders, which is the giveaway: a single passing run never proved anything here.

The symbols are now re-synced synchronously, before the option returns. That alone is not enough — the assemblies still have to be rebuilt against them, and no build can recompile itself partway through — so the clean now ends the build deliberately instead of continuing into a compile it cannot satisfy. See Changed below for what that looks like.

  • Economy — a demo overlay shipped in player builds even after Samples/ was removed. EconomyDemoOverlay sits under Integrations/ rather than Samples/ and had no assembly definition of its own, so it compiled into the shipping RevFramework.Economy.InventoryIntegration assembly. Deleting Samples/ — or running Pre-Build Clean — left it in the build, which is the one thing those actions exist to prevent. Its two demo scenes were left behind too.

It now has its own assembly constrained on REV_SAMPLES_PRESENT, the same gate every other sample assembly in the framework carries, so it comes and goes with Samples/. The unused RevFramework.Samples.Economy reference on the integration assembly went with it, since that Integrations-to-Samples edge is what let demo code live there in the first place.

Complete only. The Economy↔Inventory integration needs both systems, so no individual system package ever contained it.

No change if you keep Samples/ — the new constraints are a strict subset of where the overlay already compiled. If you had deleted Samples/ and were using the overlay, it is now gone: move EconomyDemoOverlay.cs and EconomyDemoOverlay.Inventory.cs into your own project first.

  • Currency — CurrencyJsonSave did not save currencies it was not configured with, and a load therefore left them untouched. The component saved the ids in its Currency Ids list, or a CurrencySet, or — when neither was set — a hardcoded gold/gems guess. A balance in any other currency was neither written to the file nor set by a load, so it simply survived: spend a premium currency on an item, load an earlier save, and the item is gone but the money is back. Save and load both reported success and the file looked complete, which is what made it hard to trace.

Each wallet now records the configured ids plus whatever it actually holds, so a currency missing from the list is no longer lost. Listing a currency still matters — a wallet has no entry for one it has never held, so only the list can write a line at zero for it, and that line is what lets a load put a wallet back to zero rather than leaving what it holds now.

Existing save files are unaffected and still load. Files written from now on carry more lines, which older builds read without complaint. If your currency service is your own rather than SceneCurrencyService, implement ICurrencyWalletQuery to get this — without it the component behaves exactly as before.

A load still removes nothing. A currency the file does not mention keeps its current balance, so one first acquired after the file was written survives loading it.

  • Health — the "Add DOT" and "Add HOT" inspector buttons were invisible. The HealthSystem inspector has one-click buttons for adding effect components, and two of them — DotEffect and HotEffect — were guarded on a REV_DOT_HOT_PRESENT define declared nowhere, so nobody ever saw them. Both components shipped and worked; only the convenience for adding them was missing. They now sit alongside Add Death FX Handler, Add Regen Handler and Add Shield Chain, where they were always meant to be. No define was needed — the types live in the same assembly the inspector already uses; the block was simply missing a using.

  • Status Effects — removed a dead demo-aura overlay. Four blocks in StatusUtility and StatusProviderAggregator were guarded on a REV_DEMO_AURAS define and referenced a RevGaming.RevFramework.Demos.DemoStatusPotencyAura type that does not exist anywhere in the framework. Defining the symbol would not have enabled a feature, it would have failed to compile. No behaviour change: the code could never run.

  • Currency + Inventory — the inventory-backed currency adapter did nothing, in every version since it shipped. ItemBackedCurrencyAdapter (the ICurrencyService that lets an item stack stand in for a balance, built via CurrencyInventoryFactories.InventoryBacked) bound CountOf, TryAddAllResult and TryRemoveResult on the container object by reflection. The object it probed is whatever IInventoryService.Get returns — a read-only view exposing Owner/Id/Capacity/Slots/Peek and none of those three methods. The probe could therefore never pass, and every credit, debit and balance write reported ServiceMissing while every balance read returned zero.

All inventory access now goes through IInventoryService itself: GiveExact for credits (all-or-nothing, so a partial credit can never report success), RemoveByGuid for debits, TransferResult for transfers, and Get(...).CountOf(...) for balances. Because those are interface members, every IInventoryService implementation supports them — there is no capability to probe for and no shape of a concrete container that can make the adapter inert again.

Two behaviour changes worth knowing, both consequences of the adapter now working at all: inventory authority checks apply to these mutations exactly as they do to every other inventory caller, and an adapter instance no longer caches a "container unusable" verdict for its lifetime — a previously documented limitation that disappeared along with the probe.

  • Currency, Status Effects — the TextMeshPro UI components now compile. CurrencyBarTMP, StatusBuffBar and StatusIconView are all guarded on a TMP_PRESENT define that was declared nowhere in the framework, so the define was never set for anyone: CurrencyBarTMP did not exist in any build, and the other two silently fell back to their UGUI Text paths. The three owning assemblies — RevFramework.Currency, RevFramework.StatusEffects and RevFramework.Currency.Teaching — now declare a versionDefines entry on com.unity.ugui 2.0.0 or newer, which is where TextMeshPro has lived since Unity 2023.2 (the standalone com.unity.textmeshpro package no longer exists on any supported Unity version).

This adds no dependency you do not already have. com.unity.ugui is a built-in module present in every Unity 6 project, and the define is conditional by construction — if it ever fails to resolve, the guarded code compiles out exactly as it did before. Note that TMP Essential Resources (the default font asset and shaders) remain a separate one-off project import; without them the components still compile, you just have a font field to assign.

  • Documentation — WalletSnapshot cannot be saved with JsonUtility, and every signal said it could. The type is [Serializable], documented for save/load, and its backing field name is kept stable for JsonUtility, with a comment saying so — but WalletSnapshotLine is a readonly struct with readonly fields, and Unity's serialiser skips readonly fields entirely. A round trip therefore produces the right number of lines with every field at its default: the save looks correct written to disk, and the load fails with "Invalid snapshot line". Worse, CurrencyPersistence.Restore validates a whole snapshot before applying any of it, so that single defect refuses every balance in the wallet, not just the line that caused it.

Now stated on both types and in Currency's guarantees matrix. Flatten to your own type instead, converting each CurrencyId to a string — both shipped save paths already do exactly that, each with a private line DTO. The behaviour is unchanged and deliberately so: immutability is the right shape for a captured value, and [Serializable] was never going to save you, because it makes the container serialisable rather than its readonly contents.

  • Documentation — the Teachable Panel instructions described a menu that does not exist. Five places (README.md, getting_started.md, Teaching/README.md, and the Crafting Overview) told you to open a panel from Tools ▸ RevGaming ▸ RevFramework ▸ <System> ▸ Teaching. There has never been such a menu item. Panels are components already placed in the matching sample scene — you open the scene and press Play, and the overlay draws itself. All five now say that. Related: two pages claimed teachables live in "separate teaching scenes"; there are no separate teaching scenes, and the panels are inside the sample scenes themselves.

  • Documentation site — the Inventory, Pickups & Crafting "Teachable Panels" page described Currency. The page was a copy of the Currency & Economy one, so buyers of that SKU got a page about a system they had not purchased and no description of the panels they actually had. Rewritten to cover the real 14 panels across the three systems, each naming the scene it lives in and flagging the two that are cross-system. The "How to Use" sections on all three SKU pages also told the reader to open panels from a RevFramework menu, which does not exist.

  • Documentation — an internal review note was shipping to customers. The Pickups execution-flow page ended with a "Blunt recommendation / Use this version or remove the page" note left over from an editing pass. Removed.

  • Documentation — invalid front matter on the Modules & Dependencies page. The hide: list used a Markdown bullet (* toc) instead of YAML sequence syntax, so the block was not parsed as front matter and rendered as literal text at the top of the page.

  • Documentation site — a broken landing-page card link and non-rendering diagrams. The Inventory, Pickups & Crafting card on the home page was missing the site path prefix and 404'd (its two sibling cards were correct). Separately, pymdownx.superfences was configured without a mermaid custom_fences entry, so the Pickups execution-flow diagram rendered as a code block instead of a diagram.

Changed

  • Pre-Build Clean ends the build rather than continuing into it, and now covers the demo folders under Integrations/. The option is called Clean & Stop Build: it moves the folders out, re-syncs the define symbols, and stops with a message saying the stop was deliberate. Start the build again once the recompile finishes and it goes through. What it replaces was a path whose success depended on editor timing — see Fixed above.

It moves more than it used to. Alongside Documentation/, Samples/ and Teaching/, it now takes the Samples and Scenes folders that sit next to individual integrations — seven folders rather than three in a Complete install. They are found by scanning rather than from a fixed list, so an integration demo folder added later is covered without anyone remembering to update the cleaner. Every folder found is written to the Console before anything moves, and nothing is deleted.

The trash folder now mirrors project paths_RevFramework_Trash_PreBuild/Assets/RevFramework/… — instead of flattening each folder to its own name. Three of the newly covered folders are called Scenes, and flattened they collided and landed as timestamped siblings with nothing left to say where each came from. Restore by moving the mirrored tree back. Tools ▸ Restore From Trash… does not cover this folder; that window reads the separate orphan-cleanup trash.

The prompt itself was rewritten. Unity clips these dialogs at roughly 500 characters, and with seven folders the list was consuming the budget and cutting off the explanation of what the buttons did. The list moved to the Console; the body now describes the outcome of each choice.

  • Crafting teaching panels — the late-resolve recovery no longer depends on the panel being drawn. CraftingRealAdaptersPanel and CraftingRoutingSpaceCurrencyPanel retry their OnEnable router/policy setup when the CraftingService shows up later than they do. That retry used to live only in the draw path, so a recovery that exists precisely for a mis-ordered scene was itself conditional on the panel rendering. It now runs from Tick as well.

Behaviour is a superset of before: the draw-path call is still there, both self-guard on the same snapshot flag, and Tick runs first in the same OnGUI. The one cost is that a scene with no CraftingService now does two FindAnyObjectByType per frame instead of one while the panel sits on its "Crafting Service Missing" guard.

  • Currency — CurrencyJsonSave no longer writes an entry for every object in the scene. A StableId identifies anything worth naming, not just wallet owners, so the file grew with the size of your scene rather than with the number of wallets — and loading it back called SetBalance on each of those objects, creating a wallet they never had. It now writes only owners that hold one, using the new ICurrencyWalletQuery above.

Existing save files still load unchanged, and a wallet holding nothing is still written — an empty wallet is not the same as no wallet, and dropping it would mean a file could never restore a player back down to zero. A currency service that does not implement the new interface keeps the previous behaviour exactly.

  • Status Effects — StatusIconView's stack label is now a TMP field. With TMP_PRESENT correctly set, the component's serialized stackText field takes its intended TMP_Text type instead of the UGUI Text fallback. Upgrade note: if you built your own prefab from StatusIconView and assigned stackText to a UGUI Text, that assignment will be empty after upgrading — re-assign it to a TextMeshProUGUI. Only the stack-count label is affected; icon and radial-fill wiring is untouched. No content shipped with the framework is affected: no bundled scene or prefab references StatusIconView directly, because StatusBuffBar builds its icon views at runtime.

  • Currency — CurrencyBarTMP now appears in the public API snapshot for RevFramework.Currency, since the type exists in a build for the first time. Purely additive.

Compatibility

  • Snap Studio Pro — a meta GUID collision breaks projects holding both; update Snap to 1.3.3. Three assets in Snap Studio Pro's RuntimeKit shipped carrying the same meta GUIDs as unrelated RevFramework files — HealthSystem.cs, BatchRenamerWindow.cs, and the Documentation folder — having been copied out of RevFramework with their .meta attached. Unity matches a .unitypackage entry by GUID before path, and where the GUID is already present it overwrites that asset wherever it already sits rather than creating the file at the path the package specifies. Importing both products into one project therefore wrote RevFramework's HealthSystem source into Snap's RuntimeKit folder, where an assembly that references nothing in RevFramework tried to compile it, while RevFramework's own Health assembly never received the file and lost the HealthSystem type — a wall of CS0234 and CS0246 errors across both products. Which side broke depended on import order.

Nothing in RevFramework changed, and no RevFramework GUID moved: the fix ships in Snap Studio Pro 1.3.3, which takes new GUIDs on its side. If you own both, update Snap. A project already broken by this will not repair itself on update — the overwritten file is on disk — so delete Assets/RevFramework, Assets/RevGaming/SnapStudioPro and Library/, then re-import both.

Internal

  • The build-bleed audit now examines teachable code that lives outside Teaching/. Audit Package for Build Bleed only walked assembly definitions inside Teaching/, so the three panels that consume it from elsewhere were never checked — precisely the files that broke when the folder was removed. It now requires every script outside Teaching/ that uses the Teaching namespace to sit behind #if REV_TEACHABLES, unless its assembly is constrained on the symbol instead. Script ownership is resolved nearest-assembly-first, matching Unity, since one of those panels sits in a subfolder with no assembly definition of its own.

It follows the same triage rule the framework already uses here: the question is whether the source is gated, not whether an assembly reference still resolves. A name-based reference left dangling by a removed Teaching assembly stays deliberate.

Development tooling — this file is not part of any shipped package.

[1.0.4] — 2026-07-26

Added

  • Status Effects — StatusEffectController.TimeMode read-only property. Read-only counterpart to the existing SetTimeMode(...), returning the mode the controller actually holds. There was previously no way to query it, so a caller could set a mode before the controller existed, have it silently dropped, and display a mode that had never been applied — which is exactly the v1.0.3 StatusSnapshotsTime panel bug. Purely additive: no existing signature, behaviour, or serialized data changed.

  • Render smoke test across every teachable panel. PanelRenderSmokeTests discovers all concrete TeachablePanelBase subclasses by reflection and asserts each renders without throwing when its dependencies are absent — the PanelDependencyGuard contract, and the automated form of the release checklist's "no panel throws into the Console". Panels added later are covered with no change to the test. Catches crashes, not silent no-ops.

  • Regression tests for the v1.0.3 panel late-binding fixes. Tests/PlayMode/Teaching/ covers all four panels (five tests — the Health panel's fix had two distinct failure modes), each reproducing the frame-0 ordering by enabling the panel before its target exists. Note these require a graphical PlayMode run — TeachablePanelBase drives Tick() from OnGUI(), which does not fire under -batchmode -nographics.

  • Health — HealthSystem.DeathAverted event. Raised when a hit that was lethal at the moment it landed did not result in death, because a damage-event listener restored health before the death flow resolved — an "emergency heal on taking damage" setup, for example. Previously the target survived silently with no event of any kind. Died does not fire when DeathAverted does, and before-death handlers are deliberately not consulted on this path, so a single-use handler such as ExtraLifeTotemHandler is not consumed by a hit the target already survived. Kill() is unconditional and never reports an averted death. Declared on HealthSystem only, not on IHealthMutator, so existing implementers of that interface are unaffected. A designer-facing onDeathAverted UnityEvent is exposed alongside it.

  • Adversarial test coverage for Health, Status Effects, Currency, Economy, and Inventory. New hostile-consumer tests covering event re-entrancy, throwing designer hooks, failing compensation paths, corrupt and future-versioned save payloads, and reused idempotency keys — hostile call sequences rather than hostile inputs, the axis the existing suites did not reach. The one gap that shipped as an [Ignore]d test — a nested re-entrant hit having its damage report overwritten by the outer frame — has since been fixed and the test enabled; see the last-damage report entry under Fixed.

  • Economy — EcoOpCode.IdempotencyMismatch. Reports a request id replayed with a different transaction payload than the one it first succeeded with. Appended to the end of the enum rather than grouped with the other rejection codes: the members take implicit values, so inserting it in the logical position would have renumbered UnknownError and changed the meaning of any code already persisted or sent over a wire.

  • The test suite is now available as a free, opt-in download for owners. RevFramework carries more test code than runtime code — adversarial (hostile call-sequence) and behaviour-pinning suites across every system. These are deliberately not bundled in the package: a game project should not carry a framework's test suite, and dropping dozens of framework test assemblies into a customer's Test Runner serves no one. They are offered instead as a separate, version-matched download, free to anyone who owns a RevFramework SKU — for developers extending the framework, subclassing its components, or wanting a regression net around their own changes. Discoverable from the package README, the per-SKU documentation, and a link in each welcome window. This adds a distribution channel, not a runtime feature; the framework itself is unchanged.

Changed

  • Currency — a failed snapshot-restore rollback is now logged instead of passing silently. CurrencyPersistence.Restore rolls back already-applied lines when a later line fails, and that rollback is best-effort: a compensating write can itself fail against a cap policy that rejects the original balance, revoked authority, or an escrow hold on the same wallet. The returned result always describes the original failure, so there was previously no way for a caller — or a support inbox — to tell "restore failed, state rolled back cleanly" apart from "restore failed, rollback also failed, wallet is now inconsistent". Each failed compensating write now logs an error naming the currency, owner, target balance, and result code, with the owner as the log context object. Behaviour is otherwise unchanged: restore was already documented as best-effort with no atomicity guarantee, and still is.

Removed

  • Breaking — ReflectionResolveUtility has been removed. This is the first breaking change in RevFramework's history, so it is worth being explicit: if your project used RevGaming.RevFramework.UnityIntegration.Reflection.ReflectionResolveUtility, it will not compile after upgrading. Nothing else is affected, and the error appears the moment you upgrade rather than at runtime. It was an internal helper that let the framework reach optional systems without a hard assembly reference — a packaging problem your own project does not have, since you can simply reference the assembly. Replacements are listed below.

Replacements for the removed methods:

Removed Use instead
GetComponentInParents(go, type, includeInactive) go.GetComponentInParent(type, includeInactive)
FindFirstObjectByTypeIncludingInactive(type) Object.FindFirstObjectByType(type, FindObjectsInactive.Include)
TryInvokeStringInt / TryInvokeAnyStringInt target.GetType().GetMethod(name, new[] { typeof(string), typeof(int) })?.Invoke(target, args)
ServiceFromTransform InventoryResolve.ServiceFrom(...) / CurrencyResolve.ServiceFrom(...)

The first two were thin wrappers over the Unity API named beside them. ServiceFromTransform looked for a static ServiceFrom(Transform) on the type you named, and neither InventoryResolve nor CurrencyResolve has a Transform overload — they take a MonoBehaviour, a GameObject, or a Component — so it returned null against the framework's own resolvers. Call them directly instead.

Fixed

Several integrations below did nothing at all before this release. If you had one of them set up, it will start working after upgrading — which may look like new behaviour appearing in a scene you had not touched. That is the fix, not a regression, but it is worth knowing before you upgrade a project mid-production.

  • Currency + Inventory — inventory-backed wallets did nothing. If you used CurrencyInventoryFactories.InventoryBacked(...) to back a currency with an inventory item, every balance read returned zero and every credit, debit and transfer was refused with ServiceMissing. The adapter looked up part of the inventory result in a way that could never match how that result is declared, concluded the container was unsupported, and cached that conclusion for its lifetime. It affected every install; there was no configuration that made it work.

  • Currency + Inventory — a completed transfer could report failure. After the inventory system had already moved the items, the adapter re-read both balances to announce the change; if either read failed it returned NotFound or UnknownError. The money had moved, so a caller acting on that result could refund or retry a transfer that had already succeeded. A transfer that completes now reports success, and a balance it cannot read back costs only the wallet-changed event for that side.

  • Pickups + Inventory — the Give Item pickup effect never granted anything. GiveItemEffect resolved the Inventory system by type name at runtime, and those names had never been correct. It logged a warning in the Editor and did nothing, in play mode and in builds alike. It now calls Inventory directly, so a mistake of that kind becomes a compile error instead.

  • Pickups + Currency — the Give Currency pickup effect never granted anything. GiveCurrencyEffect had the same defect, and additionally searched for currency methods that do not exist on any RevFramework service. It now credits or debits through ICurrencyService directly; a negative amount debits rather than being rejected.

  • Inventory + Pickups — Pickup effects could not be used as inventory item use-effects. The bridge that lets a PickupEffect or pickup effect definition act as an item use-effect registered itself in a way that never ran, so those assets were silently unresolvable when assigned to an item. Assigning one now works as documented.

  • Crafting + Inventory — crafting could not deliver outputs unless the item database was assigned by hand. InventoryCraftingAdapter falls back to searching the project for an item database when you have not assigned one, and that search could not find a database asset. With the field left empty, adding crafted outputs to an inventory always failed. Assigning the database explicitly still gives the most predictable result, and remains recommended.

  • Integrations — a throwing event listener could break the operation that raised it. Event isolation was completed across Runtime/ in 1.0.4; the cross-system integrations were not covered by that pass. A wallet-changed handler of yours that threw could escape the transfer that raised it, and suppress the second of the two wallet events a transfer sends. Handlers are now isolated the same way as elsewhere: a fault is logged, and the operation still reports what actually happened.

  • Health — a throwing designer event listener could leave an entity at zero health but alive. The inspector-facing UnityEvents (onDamageTaken, onDamageDetailed, onHealthChanged, onHealed, onDeath, onRevived, onMaxHealthChanged) were invoked without the exception guard the C# events already had. A listener that threw — a null reference in a VFX hook is enough — aborted the damage pipeline after health had been decremented but before death handling ran, leaving the component at Current == 0 with IsDead == false, no Died event, and no damage report recorded. All designer UnityEvents now log the exception and continue, matching the existing C# event behaviour. Exceptions from these hooks no longer propagate out of the mutation call.

  • Health — the same throwing-listener gap in shields, effects, and combat state. The fix above covered HealthSystem; the rest of the system still invoked its designer UnityEvents raw. Each one fires immediately before its C# counterpart and, in several places, before real work: CapacityShield.TryAbsorb raised onShieldDamaged before the shield-broken branch, so a throwing hook left the shield at zero capacity with no ShieldBroken event and the exception escaping into the caller's damage pipeline; DotEffect and HotEffect raised theirs before the death check and stack cleanup in their tick loops, so a throw stopped the effect ticking while leaving its state live; ShieldPool and HealthCombatState had already committed Total and InCombat before the event fired, so state and notification could diverge. All 27 sites across CapacityShield, ShieldPool, DotEffect, HotEffect, and HealthCombatState now log the exception and continue, matching HealthSystem. HealEventPostRule was already isolated — HealRuleHub try/catches each post-rule — but is guarded too, so the rule holds framework-wide with no exceptions to remember. A structural test now enforces it, so a UnityEvent added later and invoked directly fails the suite rather than quietly reintroducing the bug. No public API changed.

  • Health — the last-damage report could describe the wrong hit. TryGetLastDamageReport is documented as returning "the most recent evaluated damage attempt", but the report was recorded after the damage events were raised. A listener that applied a nested hit had its newer report overwritten by the outer frame on the way out, so the report described the older hit. The same applied to the four rejection paths, where a post-damage rule could re-enter through NotifyPostDamage. The report is now recorded as soon as the context is final, before any seam that can re-enter, so a nested hit's record is the one that survives.

This also fixes a related inconsistency: reading the report from inside a Damaged listener previously returned false on a target's first hit — no report at all — even though Current had already been decremented for that hit. A listener now sees a report describing the hit it is being notified about, so the report and Current agree. Code that read the report from inside a damage listener and relied on seeing the previous hit will see the current one instead; code that reads it after ApplyDamage returns is unaffected.

  • Economy — a reused request id with a different payload silently reported success. Shop and crafting dedup replayed a request id without checking what it was first used for, so a caller that reused one for a genuinely different purchase received the first transaction's cached success: the second never ran, nothing was charged or delivered for it, and the caller was told it worked. The payload is now fingerprinted and compared, and a mismatch is refused with the new EcoOpCode.IdempotencyMismatch rather than answered with the earlier result. Applies to Buy, Sell, and Craft.

The fingerprint ignores line order and duplicate-line spelling, so legitimate retries are still recognised: a caller rebuilding a basket from an unordered collection, or sending [gold:50, gold:50] where it first sent [gold:100], is a retry rather than a mismatch — the latter matters because those two are already the same transaction once money lines are merged. Genuine retries, and calls with no request id, behave exactly as before.

  • Status Effects — removal listeners could still see the effect they were told was removed. StatusEffectController raised its removal events before dropping the effect from the active list, so a StatusRemoved handler that read Active or HasStatus still saw it — UI rebuilt from that list kept showing an effect that no longer existed. The expire path already did the opposite, so the same outcome ("an effect ended") exposed different state depending on how it ended. RemoveStatus, RemoveStatusAt, ClearAll, Dispel, and CleanseByTag now untrack before notifying, matching expire. This also closes a latent hazard on those paths: with the events raised first, a listener that synchronously removed another status left the loop index stale, so RemoveAt could drop the wrong element. Listeners that read controller state during a removal event now observe the effect already gone; listeners that only use the supplied id and context are unaffected.

  • Status Effects — a throwing event listener could strand a removed effect as still active. StatusEffectController raised its lifecycle events — the designer UnityEvents and the C# events alike — without an exception guard. Every removal path tears the effect down with Remove(gameObject), raises the events, and only then drops it from the active list, so a listener that threw left an effect that had already been torn down still tracked and still ticking. ClearAll was the worst case: it tears every effect down in its loop and clears the collections afterwards, so one throwing listener stranded all of them. Dispel and CleanseByTag additionally abandoned their loop part-way and never returned their count. All 33 invocations across the 11 call sites now log the exception and continue, so the cleanup after the events always runs. Event order is unchanged.

  • Status Effects — a Replace apply gated on authority twice, and could duplicate an effect. The Replace stacking path called the public RemoveStatus, which re-queries authority, so a single application consulted a caller-supplied IStatusAuthority twice — contradicting ApplyStatusCore, which documents itself as existing so an entry point validates exactly once. Beyond the redundant call, this made a partial apply reachable: an authority that granted the first query and denied the second removed nothing but still added the incoming effect, leaving two effects sharing an id under a rule that guarantees one. Authority is caller-supplied and may legitimately change between calls — losing network ownership mid-frame is the case the abstraction exists for. The Replace path now removes through an internal helper that does not re-gate. RemoveStatus itself is unchanged: it still checks authority and behaves exactly as before when called directly.

  • Status Effects — ApplyOrRefresh ran the caller's factory before checking authority. A denied application still invoked the Func<IStatusEffect> build delegate, so side effects inside it — spawning VFX, consuming a charge, rolling RNG — happened for an application the controller then rejected. Authority is now checked before the factory is invoked. Note this is observable: a factory that previously ran on a denied apply no longer runs at all.

  • Status Effects — an exception thrown by an ApplyOrRefresh factory propagated to the caller. The method is void and documented only in terms of silent no-mutation outcomes, so a throwing factory escaping it was undocumented. The exception is now logged and the call performs no mutation.

  • Status Effects — authority and immunity were each evaluated twice for one ApplyOrRefresh call. The method ran its own immunity check and then delegated to ApplyStatus, which re-ran both gates. Caller-supplied IStatusAuthority and IStatusImmunity implementations that count, log, or derive state from queries saw double traffic for a single application. Both entry points now gate once and share a common apply path. No public signature changed.

  • Inventory — a corrupt save file threw out of InventorySnapshots.ApplyJson instead of returning false. The method is documented as returning true when the snapshot was parsed and applied and false otherwise, but the underlying JsonUtility.FromJson call was unguarded, so a truncated or malformed payload — a save interrupted by a crash or a full disk — raised an exception on the load path. Parse failures are now caught, logged with the parser's reason, and reported as false. Existing state was never at risk: the failure happens before any mutation, so a rejected snapshot leaves the inventory and equipment untouched, and that is now covered by a test. LoadFromFile is guarded by the same change.

  • Pickups — a decorator creator that returned null corrupted the effect chain. PickupEffectFactory.BuildEffect assigned each creator's result straight back into the chain, so a creator that failed by returning null — rather than by throwing — replaced the accumulated effect with null. With one decorator configured the method returned null outright, despite a valid core effect having been created, which its documented return contract allows only when the definition is null or the core itself failed. With more than one, the outcome was worse: later decorators wrapped the null and BuildEffect returned a live effect object with a null inner, so the failure surfaced as a null reference when the pickup was collected rather than when it was built. A creator returning null (or a destroyed effect) is now skipped with a warning and the chain continues from the last good effect — the behaviour the throwing path already had by accident, since an exception aborts the assignment.

  • Crafting — restored craft jobs ignored their snapshot schema version. CraftJobSnapshot.version is documented as being "for forward-compat" and is stamped by SaveActiveJobs, but no restore path read it, so a job saved by a newer build was restored with whatever fields this build understood and the rest silently dropped. A craft job carries currency already taken from the player and a stable completion transaction id, so a misread snapshot could deliver, refund, or charge against the wrong values. Snapshots declaring a version newer than the running build are now skipped with a warning, per snapshot — the rest of the queue still restores. Version 0 (saves written before the field was populated) and the current version both restore as before, so no existing save is affected. RestoreJobs also gained the XML docs it never had, covering all four skip conditions.

  • Inventory — snapshots declaring an unsupported schema version were applied blind. GameInventorySnapshotDTO.version was written on capture but never read on restore, so a snapshot from a future build would be applied with whatever fields this build understood and the rest silently dropped. Snapshots declaring a version newer than the installed build now log an error and return false. Older and matching versions load exactly as before, so no existing save is affected — every snapshot written to date declares version 1.

  • Crafting — a throwing job-event listener could strand a craft job. CraftingService raised its lifecycle events — the designer UnityEvents and their C# counterparts alike — without an exception guard, and several paths sequence real work after the emit: acceptance raises enqueue/accepted and only then promotes the queue with TryStartQueuedJobs, and the completion and failure paths raise before the job is removed and the next queued job starts. A listener that threw — a null reference in a UI hook is enough — aborted the emit sequence and everything sequenced after it, so a job could be left queued but never started, or finished but never cleared, with the exception escaping into the caller. All lifecycle emits (enqueue, accept, start, progress, complete, fail, cancel, the combined lifecycle event, preflight rejection, and craft XP) now log the exception and continue. Event order is unchanged. No public API changed.

  • Currency — a throwing wallet or audit listener could half-finish a transfer. SceneCurrencyService raised OnWalletChanged and its designer UnityEvent, and AuditedCurrencyService raised its audit-entry event, without an exception guard. Transfer raises each twice back to back — once for the debited wallet, once for the credited one — so a listener that threw on the first raise skipped the second entirely and propagated out of a transfer whose ledger mutation had already committed, leaving wallet-change observers or the audit trail recording only one side of money that had already moved. The escrow-expiry pump, the JSON save/load hooks, and the batch-emit seam had the same unguarded raise. All now log the exception and continue, so both sides of a transfer are always emitted and the exception never escapes the operation. No public API changed.

  • Pickups — a throwing feedback listener could diverge state from notification. ShieldSystem raised onShieldDepleted/ShieldDepleted after zeroing the shield, and InteractablePickupBase raised onPickupFailed/PickupFailed on a failed collection, both without an exception guard. A listener that threw aborted the caller's trailing work — the pickup path's hold-timer reset, the paired C# event — and escaped into the caller after the state change had already committed. Both now log the exception and continue. No public API changed.

Internal

  • Integrations/ now has automated test coverage. It previously had none, which is why the defects above went unnoticed across several releases. Alongside per-adapter tests, three structural rules now guard the category those defects belonged to — silently inert integration code — rather than only the individual cases. RevFramework.UnityIntegration has also been added to the public-API snapshot gate, so a change to its surface is caught by tests and blocked at packaging, as it already was for the seven systems.

[1.0.3] — 2026-07-18

Changed

  • Economy now declares its Currency requirement at the assembly level. RevFramework.Economy.asmdef gained "defineConstraints": ["REV_CURRENCY_PRESENT"], matching how every cross-system integration assembly in the framework already gates itself. Economy is an orchestration layer over Currency ledgers and has never been usable without it — its bootstrap is compiled out entirely when Currency is absent. Previously the assembly still built in that state, so removing Currency left Economy present, silently inert, and its demo scenes dead with a clean console. It now simply is not built, so Economy types stop resolving and any code using them fails at its own call sites, where the problem actually is. Projects that keep Currency are unaffected. This also makes modules_dependencies.md accurate, which already documented Economy as unsupported without Currency.

Compatibility

  • Unity 6.5 (6000.5) — silenced the FindObjectsSortMode obsoletion warnings. Unity 6000.5 deprecated FindObjectsSortMode and the FindObjectsByType overloads that take it, directing callers to the parameterless FindObjectsByType<T>() / FindObjectsByType<T>(FindObjectsInactive) forms. Those replacement overloads do not exist before 6000.5, so adopting them would break compilation on Unity 6.0–6.4. The 17 call sites therefore keep the sort-mode overload, wrapped in a scoped #pragma warning disable CS0618 with the reason stated inline. This is deliberate, not an oversight: it keeps a single code path across 6.0 LTS → 6.5+ with no version-specific code, and prevents the warning from becoming a build failure for projects that escalate obsoletion warnings to errors. No call semantics changed — every site retains its original FindObjectsInactive behaviour. These will move to the new overloads when the minimum supported version reaches 6000.5.

Fixed

  • Deleting Status Effects or Pickups left their integration assemblies behind, producing compile errors. Three integration assemblies declared a defineConstraints entry for the system they integrate with, but not for the system they belong toStatusEffects.HealthIntegration and StatusEffects.PickupsIntegration were missing REV_STATUS_PRESENT, and Pickups.HealthIntegration was missing REV_PICKUPS_PRESENT. Each therefore kept compiling after its own system was removed, then failed on every type it imported from it. Removing Status Effects from Health & Status Effects produced 28 errors; removing Pickups with Health present did the same in Complete. All three now constrain on both systems, matching every other integration assembly, so they are simply not built when either system is absent. Removing a system from the systems you don't need is a supported workflow and now behaves like one.

  • Welcome window reported the wrong version. The version line was hardcoded to RevFramework v1.0.0, so every install since the initial release displayed v1.0.0 regardless of what was actually installed — in all four welcome windows, which share one base class. A customer reading the version off that window would report it in good faith and send support to the wrong build. The window now reads from a new RevGaming.RevFramework.Core.RevFrameworkVersion constant, which also gives you a supported way to query the installed version from your own code: RevFrameworkVersion.Current returns "1.0.3". It lives in RevFramework.Core, so it resolves in every SKU and in Complete.

  • Teaching panels — system bindings silently dropped when the target resolved late. Four panels performed a one-time binding in OnEnable guarded by an early return on an unresolved reference, then re-resolved that reference later (in Tick, or via the draw-time dependency guard) without retrying the binding. If the target did not exist yet at OnEnable — script execution order, a spawned target, or a service enabled after the panel — the binding no-opped and was never retried, so the panel appeared fully wired while the underlying system had never been configured. Each now re-applies its binding whenever the target actually re-resolves. Demo/teaching content only; no runtime system behaviour changed.

  • Crit-Execute-BuffsHealthSystem.SetShield was never called, so damage applied from Preview/Apply used no shield until the user happened to open the Shield tab (the only other call site). The same gap left the shield bound to the previous victim after changing victim at runtime.
  • Snapshots-and-TimeStatusEffectController.SetTimeMode was never called, so the panel displayed a time mode the controller had never been given. Opening the Time tab did not recover it either: that call site only fires when the mode changes, so the user had to switch modes away and back.
  • Crafting Real-Adapters and Crafting Routing/Space/Currency — the teaching output router (and, for the latter, the chance-space policy) was never installed on CraftingService. Because the same call sets the _snapshotted flag, RestoreServiceState() also stayed permanently disabled — so any router or policy the panel applied later was left stamped on the live service on disable, with no restore.

[1.0.2] — 2026-06-25

Compatibility

  • Unity 6.5 (6000.5) — restored clean compilation. Unity 6000.5 promoted Object.GetInstanceID() and the implicit SceneHandleint conversion (scene.handle) to compile errors. These are replaced throughout with version-portable equivalents: scene caches key on the Scene struct directly, and transient runtime identity keys and comparers use RuntimeHelpers.GetHashCode — a single approach across all supported versions, with no per-version conditional code. No public API or runtime behaviour changed. Verified compiling on Unity 6000.3 and 6000.5; the replacements use only APIs available since Unity 6.0.

Fixed

  • StatusEffects — expiry removal is now re-entry safe. When an effect expired during the tick loop, the controller removed it from its active list by a cached index taken before calling the effect's Remove. A custom effect whose Remove re-entered the controller and mutated the active list (e.g. removed another status) could make that index stale, removing the wrong element or throwing. Removal is now by reference, re-finding the entry after any such mutation — matching the tick loop's existing snapshot/re-validate safety.
  • Pickups / CompositeEffect — a throwing child no longer aborts the rest. A composite applied its child effects with no per-child isolation, so one child that threw skipped every child after it (e.g. a misconfigured cosmetic effect could cancel a gameplay effect ordered later). Each child is now isolated (warn + continue), matching the decorator's hook-isolation behaviour, so the composite always attempts every child.
  • Health / Overheal — heal at full HP now spills fully to the overheal shield. With SpillOverhealToTempShield enabled and an overheal shield present, a heal that crossed the max cap spilled its overflow to the temp shield, but a heal received while already at full returned with no effect and spilled nothing — a one-HP cliff (the same heal granted 24 shield at 99/100 but 0 at 100/100). Overheal spill is now continuous: a heal that can't fit in health spills its full shortfall to the temp shield regardless of starting HP.
  • Crafting — under-charge / refund-mint on a clamped currency charge. The default craft path recorded the requested recipe cost as the job's charged amount and charged via a bool-only debit. A Clamp-mode currency policy could debit less than the cost while reporting success, so the craft proceeded under-charged and any later cancel/delivery-failure refunded the full recorded cost — minting the difference. The charge now verifies the actual debited amount: a short (clamped) charge refunds the partial and fails, so the craft is rejected cleanly rather than under-charged. Also, on the strong escrow path, a currency capture-failure now releases the hold (refunding the held currency) instead of stranding it.
  • Currency / Wallet — currency minting on a self-transfer. A transfer whose source and destination were the same wallet wrote the single balance entry twice and the second write clobbered the first, leaving the owner with the transferred amount created out of nothing (and emitting two contradictory change events). Self-transfers (from == to) are now rejected with InvalidArgs before any mutation. Affected the base store, so every composed stack inherited it.
  • Currency / Escrow — minting on a clamped hold. A hard-hold TryHold whose debit was clamped by a Clamp-mode policy floor (escrow sits above caps) stored the requested amount, so Release/ExpireStale refunded more than was actually taken (minting) and Commit under-charged. TryHold now refunds the clamped partial debit and fails with BelowMinimum rather than placing a short hold, so a hold always reserves exactly what was debited.
  • Currency / Transactions — rollback over-correction under capped balances. CurrencyTxn, CurrencyHoldTxn, and CurrencyPurchase (TrySpend/TryGrant) rolled back failed operations by the requested amount. With a Clamp-mode cap, an operation can apply less than requested, so a later failure rolled back too much — minting balance on a failed spend, or destroying pre-existing balance on a failed grant/transaction. All of these paths now reverse the actual applied delta, so a failed operation restores the exact starting balance.
  • Currency / Exchange — currency loss/gain on capped exchanges. When an exchange request exceeded a rule's maxSrc, TryExchange debited the full requested source amount while crediting only the capped amount, silently destroying the difference (e.g. exchanging 500 against a cap of 100 at rate 1 charged 500 and paid out 100). Out-of-range requests are now rejected before any funds move. Separately, when a Clamp-mode policy floor on the source currency debited less than requested, the exchange still credited the destination for the full amount (the owner underpaid); it now refunds the actual debit and fails instead of completing a short-paid exchange. The failure-path rollback also reverses the actual debited amount, not the requested one.
  • Currency / Audit — missing metadata on escrow-required stacks. When the RequireEscrow guard forwarded an operation, audit entries recorded below it lost their reason / sourceId. The guard is now transparent on the audit-aware surface and metadata is preserved.
  • Currency / Save — non-atomic save write. CurrencyJsonSave wrote saves with a direct overwrite, so a crash, process kill, or full disk mid-write could truncate or corrupt an existing save. Saves are now written to a temporary file and atomically swapped into place, leaving the previous save intact on failure.

  • Economy / Ledger — rollback over-correction under capped balances. CurrencyValueLedger.Grant credited un-adjusted reward amounts and rolled back by the requested amount, so a Clamp-mode ceiling that clamped a credit plus a later failure would over-debit pre-existing balance. Grant (and, defensively, Pay's non-escrow fallback) now reverse the actual applied delta. This is the same class as the Currency transaction fixes, in the Economy money adapter.

Changed

  • Currency / Exchange — minSrc / maxSrc are now hard bounds (behaviour change). Amounts below minSrc fail with BelowMinimum; amounts above maxSrc fail with AboveMaximum; TryQuote returns false for out-of-range amounts instead of silently clamping. To exchange "up to the cap", request maxSrc directly. No public API signatures changed.

Internal

  • Documented the Crafting station-cap convention: a per-station maxParallel of 0 (or less) means no per-station limit (unlimited, gated only by the global cap), not "block the station" — matching the global maxParallelJobs convention. Doc-only; no behaviour change.
  • Repaired two Health preview-processor EditMode tests that broke after rules began requiring their rule-hub via [RequireComponent] (they hand-added a hub the rule now auto-adds). Production behaviour unchanged. Also fixed a dropped leading character in the test filename.

[1.0.1] — 2026-06-21

Fixed

  • Packaging / Economy — removing the Currency system no longer breaks the complete package. Economy's currency adapter layer (CurrencyValueLedger, PolicyDebitUtil, CurrencyResultExtensions, DebitValidationMode) and both bootstrap facades (EconomyBootstrap, EconomyInventoryBootstrap) referenced Currency types without the REV_CURRENCY_PRESENT guard the rest of the framework uses, so deleting Runtime/Systems/Currency from the all-in-one package left dangling references (CS0234 / CS0246) in Economy. Those files are now guarded, and the Currency ↔ Inventory integration assembly plus the Economy test assemblies — which were missing the REV_CURRENCY_PRESENT define constraint — now carry it. Removing Currency from the complete package compiles cleanly; individual SKUs were unaffected.

[1.0.0] — 2026-06-09

  • Initial release. Systems: Inventory, Pickups, Crafting, Currency, Economy, Health, Status Effects.