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.Currentfrom 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.
[1.3.0] — 2026-09-04¶
Attributes is the new system: somewhere to keep the numbers a character is made of, with no fixed stat list and no opinion about what a stat means. It ships in Complete only, and it is the smaller half of this release.
The rest is correctness, and the seams the correctness work showed were missing. Two adversarial audits — authority semantics across every system, then multiplayer, determinism and dedicated-server behaviour — plus an engineering survey and an adversarial pass over the Cookbook. Out of them: ApplyStatus reports why it refused instead of returning void, save sections carry a format stamp so a foreign payload cannot restore as a silent success, code can ask whether a load is applying right now, and InteractablePickupBase enforces pickup authority itself rather than leaving it to each subclass.
Attributes adds public API under Runtime/Systems/Attributes/. Everything else changes runtime behaviour in Inventory, Pickups, Health, Status Effects, Currency, Economy, Crafting and Loot.
Read the Migration section at the end even if nothing else here applies to you. Most of this release is correctness work, and a fix to something that behaved wrongly is still a change to what your project does today. Several entries change behaviour with no compile error, and one — the save format stamp — decides whether you can roll a build back after players have saved.
Added¶
Attributes — a container for the numbers a character carries. Complete only. There is no fixed stat list and no opinion about what a stat means: an attribute is a string id, a float base value and optional bounds, authored as inspector rows or created at runtime by the first SetBaseValue for a new id. What the ids mean is your project's vocabulary. AttributeSet goes on the owner and consumers read it through IAttributeSource, found with GetComponentInParent, so nothing that reads an attribute ever references the component.
- Effective values are computed at the read, not stored. Contributions come from
IAttributeModifierProvidercomponents on the owner's chain, are folded into the base value by the project'sIAttributeCombiner, and the result is clamped to the attribute's bounds. Nothing registers, nothing pushes, and nothing has to be told when a contribution stops being true — a ring you are no longer wearing is simply not in the answer. - No combiner ships, and that is deliberate. How contributions stack — additive, flat-then-percent, best-buff-wins — is a design decision rather than a fact about containers, and a shipped default becomes what every project's prefabs reference. With no combiner wired, effective values are clamped base values and providers are not consulted at all; because that is indistinguishable from a bug from the outside, the container explains itself once in the console the first time it finds providers and nothing to fold them with.
- A combiner may read another attribute of the same owner from inside
Combine, so a derived stat needs no stored value, no update step and no invalidation. Nested reads get their own buffers, and a cycle bottoms out onAttributeSet.MaxReadDepthwith a console line rather than a stack overflow. - Base values only are persisted, through a save participant. Effective values are functions of contributions whose inputs are already saved by the systems that own them, so nothing is written twice and no two participants can disagree about a total.
AttributeLevelSourcebridges Crafting'sICraftingLevelSource, stating its conversion (FloorToInt) on the component rather than hiding it — the one adapter that ships, because how an attribute maps onto another system's stat is a decision of your game.- Effective values raise no change event. The container cannot know when a provider's answer changes, so anything that must react rather than read has to reconcile at moments it chooses. That is stated here because it shapes how you design against this system.
Four Cookbook recipes for it, each built only from the public API: a character sheet nothing writes to, three stacking rules over one unchanged container, a bag that grows with the character, and an attribute is not a multiplier — which sets out how the four stat seams Health and Status Effects already publish disagree about what "no change" means.
IEconomyRequestWindow — Economy's replay window can be rewound when a load rewinds the world. Complete and any SKU with Economy. The window remembers a successful request id so a retry short-circuits instead of charging and delivering twice, which is correct while the world only moves forward. An in-process load moves it backwards — RevSaveManager.Load restores the wallet and the containers a transaction touched — and the window did not follow, so an id used before the load was still remembered after it: re-issuing it returned the remembered result and applied nothing, against balances that had been put back. A request id is usually derived from the game state that issued it, and a load rewinds exactly that state, so the same id is the natural thing to re-issue.
ShopService and CraftingService now implement IEconomyRequestWindow (ClearForOwner, ClearAll), reached by casting the service you hold. Nothing calls it for you, and that is not an oversight: CurrencySaveParticipant clears the currency window during its restore because Currency has state to restore, while Economy has none of its own and therefore ships no save participant. Call it from wherever you handle a load, as you would CraftingService.ClearAppliedCompletions. Each service keeps its own window, so clear each one you hold. Additive: no existing behaviour changes, and a project that never calls it is exactly where it was.
This closes the Economy half of D6 from the 09-01 architectural discovery pass — session-scoped dedup state not rewound by an in-process load. Crafting always handled it and Currency gained ICurrencyIdempotencyWindow; Economy was the one of the three with no way to do it at all.
A teaching panel for every save participant. Integrations/Save/ ships five per-system participants — Inventory, Health, Status Effects, Crafting and Currency — and until now not one of them had a teaching panel or a sample scene. The only demonstration of the save layer was the shared coordinator panel, which by design references no system and therefore cannot show a real one being saved. Each panel below teaches the part of its participant a reader cannot infer from the coordinator. Teaching content only — no runtime behaviour changes anywhere.
- Inventory —
InventorySavePanel(RevFramework/Demo/Inventory/05 Save Participant). One participant covering every character in the scene rather than one you bind; the StableId mismatch that makes a valid save restore nothing; the item database the participant refuses to be built without; andMissingItemPolicy.Skipreporting success while the player quietly loses items. - Health —
HealthSavePanel(RevFramework/Teachables/Health/Save Participant). WhatHealthSnapshotactually holds — current, max and dead — and the list of what it does not: shields, regen timers, i-frames, combat state, rule state. Also the two events that never fire, because a restore writes fields directly rather than going through the kill or revive paths, and a save whose contents cannot be true being refused rather than clamped into something plausible. - Status Effects —
StatusSavePanel(RevFramework/Demo/Status/05 Save Participant). The factory contract, with both the correct and the natural-but-wrong implementation runnable side by side: the saved remaining time has to be the duration you build with, becauseApplyassignsTimeRemaining = Durationand the controller then callsRefresh(Duration × DurationScale). Passing the authored duration instead restores every status at full time and reports success. - Crafting —
CraftingSavePanel. Why recipes have no durable identity, what the asset-name convention costs when someone renames one, and why this is the only participant that declaresRevSaveOrder.Late— it writes into Inventory and Currency while it restores. - Currency —
CurrencySavePanel(RevFramework/Demo/Currency/09 — Currency Save). The duplication exploit at owner level — a wallet funded after the save was taken has no entry in it, so an apply-onto-existing-state restore never touches it — andUnsavedWalletPolicy.Zeroclosing it by default, withLeaverunnable so the exploit is visible rather than described.
RevSaveReport.SavedAtUtc — when the save was written, handed back rather than only recorded. The envelope has stamped every save since 1.1.0 and never returned it, so a game that needed to know how long a save had been closed carried a second timestamp of its own inside a participant payload, duplicating a value the file already held and could not keep in step with. The stamp is now parsed onto the report, which reaches game code through RevSaveManager.LoadCompleted.
- Set on a capture report and a restore report alike, meaning the same thing in each. A capture takes one
UtcNowand uses it twice, so the string in the file and the value on the report are the same instant rather than two calls a moment apart. nullwhen the file carried no usable stamp — a save written before this was surfaced, or a corrupted value. Deliberately not a default date:DateTime.MinValuewould read as a real instant and turn "the file did not say" into two thousand years of elapsed time.- Parsed with
RoundtripKind, so the result carriesDateTimeKind.Utc. Without it, subtracting fromDateTime.UtcNowis silently wrong by the machine's offset — an hour of free offline progress in Berlin, a negative elapsed time in Chicago. -
Nothing in the coordinator branches on the stamp; reporting it is not reading it. It is wall clock, and therefore the player's to change, so how far to trust it stays a design decision.
-
Inspector event hooks on Inventory and Loot. Inventory, Loot. Six systems already mirrored their C# events as serialized
UnityEvents under Events (Inspector – designer hooks), so a scene could react without a script. Inventory and Loot published C# events and no mirror, which was an omission rather than a decision — Health has thirty-one of them and Attributes, the newest system, shipped with one.
Eight hooks, each raised from the same place as the event it mirrors, with the same arguments, immediately before it:
| Component | Hooks |
|---|---|
LootService | On Rolled, On Granted, On Spawned, On Undelivered |
SceneInventoryService | On Container Changed |
CharacterEquipment | On Equipped, On Unequipped |
CharacterInventory | On Changed |
Nothing about the C# events changed, and no public API moved — the hooks are private serialized fields, as they are on every other system. Existing code and existing scenes behave exactly as before; there is nothing to migrate.
A throwing inspector-wired listener is contained, and on these two systems that is the point rather than tidiness. Inventory's hooks fire part-way through anything touching more than one slot, so an unguarded exception would abandon a move or a transfer half-applied; Loot's fire after awards have already changed hands, where escaping would report a failure for work that happened. Each hook is logged and cannot reach the operation, or the C# event raised after it. Two convention tests per system now enforce that structurally, so a hook added later cannot quietly be raised bare.
CharacterInventory's pair is deliberately not identical, and the difference is documented rather than resolved. OnChanged forwards subscriptions to whichever container was bound when you subscribed, and nothing re-attaches them across a disable/enable cycle — so a handler added once at startup silently stops firing. The inspector hook is wired by the component's own bind path and survives a rebind. Making OnChanged behave the same way would change what existing subscribers get, which is a behaviour change rather than an addition; Inventory's public API page now says so, and points code at SceneInventoryService.OnContainerChanged, which is raised rather than forwarded.
Economy has no hooks and is not an omission. It ships no MonoBehaviour and raises no events at all — its shops, rewards and ledger are plain C# services constructed by EconomyBootstrap. There is nothing to mirror, and inventing an event vocabulary to have something to mirror would be the wrong reason to add one.
- Currency policies and definitions can be built in code. Currency.
CurrencyPolicyandCurrencyDefinitiongainCreatefactories, matching the seamLootTable.CreateandRecipeCore.Createalready offer. Both return a runtime-only object markedHideFlags.DontSave; authoring an asset remains the recommended route, and neither competes with it.
The gap was real and it was silent. With no public construction route a policy could not be built outside its own assembly at all, so everything that needed one in code reached for UnityEditor.SerializedObject and poked the private _rules list. Fourteen test files did exactly that, six of them wrapped in #if UNITY_EDITOR with an Assert.Inconclusive("Policy authoring requires UNITY_EDITOR") fallback — a suite that silently tests nothing wherever the editor is absent. All fourteen now use the factory, and no test in the tree touches _rules any more.
CreateInstance plus field assignment was never an equivalent, which is the reason this is a factory rather than documentation. CreateInstance raises OnEnable, which builds the lookup cache from the rules present at that moment — none — leaving a cache that is empty but not null. The lazy rebuild only fires when it is null, so every subsequent lookup misses silently and permanently. The editor path survived only because ApplyModifiedProperties raises OnValidate. Create runs the rebuild explicitly, and a test pins that: remove the call and six cases go red.
Rules are copied, and the copy is load-bearing. CurrencyRuleAuthoring is a class and the rebuild sanitises rule objects in place, swapping an inverted min/max. Storing the caller's instances would apply those edits to their array.
CurrencyExchangeTable too, and it needed the decision the first pass deferred: its rate type was a private nested struct, so Rate is now public and Create(params Rate[]) joins the other two. Making it public changed no serialized data — accessibility is not part of Unity's asset format, and the field names, which are, are untouched, so existing exchange-table assets load exactly as before. Six more test files stop reaching into the type, one of which was doing it through reflection on the private struct and nulling the lookup cache by hand afterwards. Nothing in the tree now touches _rates, _rules or _map.
The exchange factory deliberately does not clamp, where CurrencyDefinition.Create does. rate and feePct carry [Min(0)], but that is an inspector affordance rather than a rule the runtime enforces, and a shipped test pins what the quote domain does with an out-of-range one. Clamping would have moved that behaviour to construction and quietly made that test unable to fail, which is the same reason LootTable.Create stores its own bounds unnormalised.
(Corrected 2026-09-04: this entry originally said "a negative fee is a meaningful input — it is a bonus". It is not. TryQuote clamps the fee into [0, 1] before applying it, so a negative feePct quotes identically to zero. Storing it unclamped is still the right call for the reason above; the "bonus" was never true.)
This is F2 of the 2026-09-01 low-code authoring review, and the constraint the 2026-08-30 engine-agnostic read independently measured as blocking a portable test suite.
- The Status Effects save participant reads the refusal instead of inferring it. Status Effects, Save. #329 gave
ApplyStatusaStatusApplyResult, and four Cookbook recipes dropped theirHasStatus-either-side workaround in the same change. The participant kept its copy, which is D3 of the 2026-09-01 architectural discovery audit.
It was not only tidier — the old reading was wrong in one case, and wrong in the direction that hides. Landing was inferred from whether the controller held the status id afterwards, compared against whether it held it before, with "already held" counting as applied. So restoring a save onto a controller that was already carrying that status, into a target that refuses it, reported a clean load: the status present was somebody else's, the saved one never came back, and nothing said so. A return value belongs to its call, so presence before the apply is no longer treated as evidence about it. Two PlayMode tests, both verified against the old participant — the already-held case passes the load there and fails the assertion.
Refusals now name their reason. The report lists NoAuthority, Immune, NoEffect or BuildFailed per entry, and an entry whose apply threw is listed separately as (threw) — the two used to be indistinguishable. Immune and NoAuthority send a reader to completely different places.
The Save guarantees matrix loses the duplicate caveat it carried for this, and two HasStatus calls per restored entry go with it.
- Two random draws that had no seam now have one. Health, Pickups.
AntiHealRule's block roll and the PickupsConditionalDecorator's chance roll were inlineUnityEngine.Random.valuecalls, so a project could only make them deterministic by seeding Unity's global generator — the single stream everything else draws from. Both can now be given their own. D5 of the 2026-09-01 architectural discovery audit.
Nothing changes without opting in. With no provider assigned, both draw from UnityEngine.Random exactly as before.
The two sites take different interfaces, and that is the correct answer rather than an inconsistency. AntiHealRule is a component, so it gets a serialized provider field and SetRngProvider, resolving IRng — matching its sibling CritRule. ConditionalDecorator is a ScriptableObject and cannot hold a scene reference, so it resolves IRandomProvider from the context's parent chain, the way ConditionalHealthPercentDecorator resolves health; that also makes the stream per-actor. The interfaces differ in range — IRng.Next01 is [0, 1) and IRandomProvider.Value01 is [0, 1] — and each site takes the one whose range matches the comparison it already used, so neither path changes meaning.
AntiHealRule deliberately has no SetRng(IRng). CritRule has one and it does not survive the next enable: it assigns the active RNG without touching the serialized field, which is then re-resolved over the top — by the second spawn for anything pooled. The new seam has one door and it is the durable one.
A wrong-typed provider warns and falls back rather than failing silently, and AntiHealRule.Rng exposes what actually resolved, because the failure this prevents is otherwise invisible.
RevSaveRestore.InProgress— code can ask whether a load is applying right now. Save. The coordinator already maintained this bit to classify partial restores and kept it private. D4 of the 2026-09-01 architectural discovery audit.
It is worth more than the finding credited it for. The scope wraps participant.Restore itself, not that participant's own bookkeeping, so anything a restoring participant raises synchronously — a container change, a balance change, a status event — runs inside it. A listener that has never heard of the save system can therefore tell that the change it is being told about is a load applying rather than something the player did, without its own participant having been called at all.
That closes a case a shipped recipe documented as unclosable. CountsWhatHappened guards on a local flag its own Restore sets, and spent a paragraph on the one case that leaves open: a save with no section for the objective, where Restore is never called and a restore delta can complete it mid-load. The inventory participant is mid-restore when it raises that delta, so the guard can now ask the framework as well as itself. The recipe does, in one added condition, and its page says so instead of naming the gap.
A shipped test had pinned that hole as a decision, and predicted its own retirement. Its comment read: "if this ever starts passing with zero completions, the framework has gained a way to suppress or attribute restore deltas — go and correct the page upward." It has, so it is inverted rather than deleted. The objective still ends up complete, because the bag really is refilled; what no longer happens is the announcement mid-load, which was the half a listener could not take back once it had granted a reward.
What it cannot tell you is that a load is running when nothing is currently restoring — between participants, or for work a system defers past the end of its own Restore. For that, set a flag around RevSaveManager.Load yourself; it is six lines and covers strictly more. Reconciling from LoadCompleted remains the recommended route for anything that can wait until the load is done.
- A load now rewinds the currency replay window with the balances. Currency, Save. The idempotency decorator remembers the result of a request id so a retry short-circuits instead of applying twice, which is right while a wallet only moves forward. An in-process
RevSaveManager.Loadmoves it backwards — and the window did not follow. D6 of the 2026-09-01 architectural discovery audit.
The consequence was quiet and in the expensive direction. A request id used before the load was still remembered after it, so re-issuing it returned the remembered result, applied nothing, and reported success — against a balance the restore had put back. Ids are normally derived from the game state that issued them, and a load rewinds exactly that state, so re-issuing the same id is the ordinary case rather than a contrived one.
CurrencySaveParticipant now clears the window as part of its restore, so this is fixed for anyone using the shipped participant without touching their code. A host that rewinds wallets some other way can do the same through the new ICurrencyIdempotencyWindow, reached with CurrencyFactories.TryGetIdempotencyWindow — the same shape as TryGetEscrow, and the same arrangement Crafting has had all along with ClearAppliedCompletions.
A stack composed without WithIdempotency is unaffected, which is most of them: the participant asks for the capability rather than requiring it.
The WithIdempotency remarks are corrected. They said the window is "in-memory and single-process: nothing survives a reload, so a request id issued before a save and retried after it is always treated as fresh" — true of a process restart, and the exact opposite of what happened on an in-process load.
Changed¶
-
Pickups —
InteractablePickupBaseenforces authority itself. All packages.TryPickupnow resolvesPickupAuthorityand refuses the interaction when a resolved authority denies the actor, beforeDoPickupis called. Until this release the base enforced nothing and gating was each subclass's job, which 1.2.0 documented in three places — two of which also said the check was scheduled for 1.3.0 with a migration note. This is that change.If your project does not use pickup authority, nothing changes.
Resolvereturns null when noIPickupAuthorityis in reach and the pickup proceeds, which is the optional-authority rule the rest of the system already follows.Migration. A subclass that already resolved and tested authority inside
DoPickupcan drop that check — leaving it in place is harmless, it resolves a second time and reaches the same answer.InventoryPickupInteractable's own check has been removed for exactly that reason. A subclass deliberately left ungated in a project that has anIPickupAuthorityin reach is now gated by it: that is the one behaviour change, and it is the case this release was scheduled for. If a particular pickup must ignore the scene's authority, scope the authority rather than the pickup — a binder on the pickup's own prefab or parent answers for that hierarchy only.A denial reports as an ordinary failed attempt —
PickupFailed,onPickupFailedand the fail feedback — which is the shapeInventoryPickupInteractablealready produced, and is deliberately indistinguishable from a full bag or a missing service. Pickups has no result type by design: branch inside your authority implementation, not on the outcome. -
Health —
SetMaxHealthClampNoDeathEventsis[Obsolete]. All packages. Its name says it suppresses death events specifically while leaving others alone. It does not, and never has: likeSetMaxHealthSilent, which it forwards to, it emits nothing at all, max-change included. Behaviour is unchanged and the method is not going away — useSetMaxHealthSilent(value, clampCurrent: true), which is the same call and also offersclampCurrent: false.Only this one of the six overlapping setters is marked.
SetCurrent/SetCurrentHealthandSetMax/SetMaxHealthalso forward to siblings, but they are honest names for what they do and mislead nobody; between them they have several hundred call sites, nearly all in this framework's own suite, so deprecating them would have meant a large mechanical churn or a wall of warnings that buries the one signal worth reading. A name that makes a false claim about behaviour is the part that can cost someone an afternoon.MaxHealthModifierStack.ClampMode.ClampNoDeathEventskeeps its member — it is serialized, and removing it would silently repoint every prefab holding its value — and now routes toSetMaxHealthSilentdirectly, which is what it always did one call further down. -
Currency — an audit layer under a decorator is reachable again, and "empty" now means "not audited". All packages with Currency.
CurrencyAudit.GetandTryGetwere a single cast on the service handed to them, so a stack with a decorator above the audit layer —WithEscrowoverWithAudit, which every shipped escrow factory composes — returned empty while the layer underneath recorded every entry. Empty was therefore ambiguous between not audited and audited, but hidden, and a consumer given the service by someone else's bootstrap could not tell which, or recover the reader at all:ICurrencyServiceDecorator.Inneris internal plumbing.The helpers now resolve the reader by walking the composed stack, and
CurrencyFactories.TryGetAuditReaderexposes that walk directly — the same shape as theTryGetEscrowandTryGetIdempotencyWindowbeside it. Escrow stays outermost by design, because that is what exposesICurrencyEscrowand satisfiesCurrencyPolicy.RequireEscrow; it just no longer costs you audit reading as a side effect.Additive, and a behaviour change only where the reader was previously unreachable: a hand-composed stack that returned empty may now return entries, which is the defect being fixed. The default bootstrap composes
WithCapsAuditAuthority, which forwards the reader explicitly, so a project on the shipped path was never affected. -
Inventory and Status Effects — the last two publicly reachable events gain per-listener isolation. All packages. One throwing subscriber must not abandon the emit or reach the caller — the convention applied at this framework's event-raising sites, with the reasoning where each one sits.
CharacterEquipment.OnEquipped/OnUnequippedandStatusUseEvents.RaiseAppliedstill raised directly.InventoryContainerandEquipmentContainerdo too, but they areinternaland their gap is closed downstream bySceneInventoryService.SafeInvokeChanged.StatusUseEvents.RaiseAppliedis the one worth calling out: it is a public static raiser, so the exception propagated into whichever integration called it — code that has already applied the status and is only reporting it. One careless subscriber anywhere in a project could fail an item use that had worked, and take every subscriber registered behind it with it.A throwing subscriber is now logged and swallowed, order is preserved, and every subscriber runs whatever the ones before it did. This changes what a throwing handler does: it no longer reaches the caller. The
UnityEventmirrors keep a single guard around the wholeInvoke, because Unity exposes no way to enumerate aUnityEvent's runtime listeners — that asymmetry is the same everywhere it appears. -
Inventory —
OnUnequippedis raised after the deferred containers flush, likeOnEquipped. All packages.TryEquipFromInventoryResultraised its pair outside theusingblock with a comment giving the reason: by there the transaction has committed and both deferred containers have flushed, so a handler reads the finished move, and a throwing handler cannot unwind a transfer that already succeeded.TryUnequipToInventoryResultraised its pair inside both scopes, three lines from that comment. A handler therefore saw committed state whose container events had not fired, and a throwing handler propagated out through both disposals — firingOnChangedduring the unwinding and handing the caller an exception for an unequip that had worked.Existing coverage was happy-path only, which is why an asymmetry documented in the same file survived. Three tests now pin the ordering on both paths and the throwing case.
-
Pickups — the facing check can be told which plane it is measuring in. All packages.
InteractablePickupBase.facingPlane(PickupFacingPlane.Auto/XZ/XY) replaces a per-call guess.Autois the default and is the old behaviour, so nothing existing moves.The guess compared
|y|against|z|using the geometry of the one pickup being tested, which is correct only while everything shares a height. Worked through: top-down XZ, an actor facing +X, a pickup one unit ahead and two units up. The direction normalizes to(0.447, 0.894, 0), the guess picks Y, and the dot against a facing of(1, 0)is 0.447 — so at a threshold of 0.5 the pickup the actor is looking straight at is refused, and noIFacingProvidercould correct it because the error is in the flattening. Set it to the plane your game uses. -
Pickups —
IFacingProvideris found on the actor's children, not the root alone. All packages. The same class resolved its two optional interfaces two different ways, 28 lines apart:IInputServicewalked component → children → scene,IFacingProviderwas aTryGetComponenton the root. A provider on a rig or a sprite child was never found and the actor's transform answered instead, silently. The walk now matches its sibling, minus the scene-wide step — "someIFacingProvider" is not an answer to "which way is this actor facing". -
Health —
CritRule.SetRngsurvives reconfiguration. All packages. It wrote the active generator and nothing else, so the nextConfigureRng— fromOnEnable, or from any of the three policy setters — silently put the configured one back. Measured: the installed generator was consulted once, for the hit that followed the install, and never again, with nothing logged. On a pooled enemy that is the second spawn, so the first fight reproduced and every fight after it did not. The serialized route (ExternalProviderplusrngProvider) never had the problem becauseConfigureRngre-resolves that field, so the two documented ways in behaved differently and only the one that is not a method survived.SetRng(null)now takes the override back and returns to whateverRngPolicysays. TheReplayableCritsrecipe, whose whole premise was that only the serialized door held, is corrected: the serialized route is still what that recipe uses and still the right choice for it, because it is the one an inspector can express. -
Documentation — "restores are silent" is corrected; it was true of two participants out of six. All packages, documentation only — no behaviour changes. The save guarantees matrix carried the row "Events raised during a restore — Participants write state directly: no
Died, noRevived, noHealthChanged", which is Health's contract stated as if it were the framework's. The same claim was repeated across the Save overview and Public API pages,RevSaveManager's own XML docs, the Attributes participant, and three Cookbook recipes.It is true of Health and Attributes, which write state directly. It is false of the other four, because a participant restores through whatever API its system exposes and most of those raise on a write: Currency applies each line with
SetBalance, which raisesOnWalletChangedand itsUnityEventmirror; Inventory's container and equipment raise their change events as they are written; Status Effects restores throughApplyStatus, soStatusApplied/StatusRefreshed, the…Ctxvariants, theUnityEventmirrors and refresh FX all run; and Crafting completes offline jobs during the load, emitting XP and completion and raising Inventory's and Currency's events as it delivers and refunds.The stated reason for the silence — that firing events during a load would spawn VFX — is therefore something a Status Effects restore does today. The advice is unchanged: reconcile from
RevSaveManager.LoadCompleted. What changes is what you may assume before it fires. Treating the absence of an event as proof that no load is running was only ever safe for two of the six, and there is no framework-wide "a restore is running" signal to read instead — a flag around your ownLoadcall is the whole of it, and the matrix now shows how.Suppressing those events is deliberately not the fix: restoring through the public API is what makes a restored status re-establish itself, a restored wallet respect its caps, and a restored container refuse what it cannot hold.
-
A use effect that refuses its payload now keeps the item, whatever its siblings did. Inventory.
ItemUseSystemcounted the effects that delivered and consumed the item when the count was non-zero, so a single sibling effect — a heal, a buff, a VFX, and every effect that cannot report at all is assumed to have delivered — was enough to discard another effect's refusal. A pouch the bag had no room for was consumed, its contents never arrived, and the call reported success. One refusal now vetoes the consume and the call fails withInvOpCode.Partial.
This is what IUseEffectReportsDelivery has always told implementers to expect — "a partial delivery counts as refused, since the caller is deciding whether to consume the item" — and what Pickups already did: CompositeEffect has reported delivery only when nothing refused since it shipped. The two systems disagreed about one question; this is the side whose reasoning was written down.
Effects that did deliver are not rolled back. The heal has happened and the item has not been spent. That is the same non-transactional property already documented for a consume the inventory rejects, and it is the honest half of the trade: the alternative is a rollback the effect seam cannot express.
One thing to know before you ship it. This changes behaviour with no compile error. If you have a usable item carrying more than one effect, and one of them can refuse, that item stops being consumed in cases where it previously was. Items with a single effect are unaffected, as are items whose effects never refuse — which is every effect that does not implement IUseEffectReportsDelivery. - ApplyStatus and ApplyOrRefresh now report why they refused. Status Effects. Both returned void, so an authority denial, a null effect and target immunity were the same silent nothing: a server-authoritative project could not tell "applied" from "refused" at all, and the guarantees matrix promised that effects do not mutate without authority without ever saying the caller is not told when they don't. Health has reported the same condition since launch, through AuthorityDenied and LastAuthorityError; Currency returns CurOpCode.Unauthorized. The house standard existed and had not been applied here.
Both methods now return a StatusApplyResult — Applied, NoAuthority, Immune, NoEffect, or BuildFailed for a factory that threw. An enum rather than a result object because there was no fourth result shape worth inventing, and because StatusAuraZone applies through this path once per target per repeat interval, which is no place for a per-call allocation.
There is one success value on purpose. Applied covers apply, refresh and stack alike. Splitting it into applied-versus-refreshed would read as more informative and behave as a trap: every == Applied already written would start failing the moment an effect refreshed instead. That distinction stays on the StatusApplied and StatusRefreshed events, which is where it belongs.
A StatusApplyRefused(StatusId, StatusApplyResult) event fires on every refusal and never on a success. It is not a duplicate of the return value: the return answers the caller, the event answers everything else — a HUD, a combat log, an analytics hook that needs to know an aura's tick was refused without owning the code that called the aura. There is deliberately no LastApplyResult property; with a return value, a third way to learn one fact is a liability.
Your code still compiles. Changing a void return does not break callers — a call that ignores the value is still valid C# — and that was measured, not assumed: all 46 Cookbook recipes, every sample, integration and teaching panel compiled untouched. The only source-breaking case is a project that wrote its own IStatusEffectController implementation, which must now return the type. The framework contains exactly one implementation of that interface.
ApplyOrRefresh was fixed alongside it rather than left for later. It had four silent-nothing paths to ApplyStatus's three, and fixing one while leaving the other would have made the new guarantees-matrix row untrue for half the surface it describes.
- Four Cookbook recipes lose the workaround they were built around. Status Effects.
StatusOnHit,CraftedStatusEffectsandCursedEquipmenteach readHasStatusbefore and after an apply to infer a refusal, and each documented the same honest limit: presence cannot tell a refusal from an effect that was already running, so a target already carrying that id absorbed the refusal unnoticed. All three now read the returned result, and that limit is gone rather than described.
The ability recipe — AbilityInFourParts — carried about forty lines of it: subscribe to OnStatusAppliedCtx and OnStatusRefreshedCtx across the single call, unsubscribe in a finally, check the context coming back was yours, and refuse re-entrancy because two nested casts of one component cannot be told apart on a channel that identifies a source rather than an application. It closed by observing that "a result-returning ApplyStatus is the only airtight closure". That ask is what this release answers, and the recipe now makes the comparison in one line. The reasoning is kept rather than deleted — the HasStatus trap and the source-versus-application distinction apply to the next seam that reports nothing, and the next one will not come with a release note.
-
The five system teaching assemblies now reference
RevFramework.Coreand their matchingRevFramework.Integrations.Save.*assembly. Each integration carries the sameREV_*_PRESENTconstraint as the teaching assembly that now references it, so they are always present or absent together and deleting a system still removes both. -
Save sections now carry a format stamp, and every shipped participant moves to version 2. Attributes, Crafting, Currency, Health, Inventory, Status Effects. A section is addressed only by its key, two participants can choose the same key, and
JsonUtilitynever fails on a document it does not recognise — a field the JSON omits comes back empty rather than null. So a payload written by something else, under one of these keys, used to restore as a silent success that applied nothing. Each participant now writes a stamp naming itself and requires it back.
Your existing saves still load. Sections written before this change are version 1, cannot carry a stamp, and are accepted exactly as before — refusing them would refuse every file already on a player's disk. The protection applies from version 2 onward, so it arrives for a given save the first time that save is rewritten by this build.
One thing to know before you ship it. A save written by this version records its sections as version 2, and an older build of RevFramework refuses a version it does not understand — as it always has, loudly, without applying anything. Rolling a build back after players have saved means those saves will not load in the older build. That is the ordinary cost of a format change and it is called out here rather than discovered.
-
Crafting —
SetOutputRouternow says why it refused a router. Crafting. An output router must be aMonoBehaviour, because it is stored in a serialized field so the inspector and a code-first caller end up in the same place. A plain C# router was refused through the return value alone, and nothing obliges a caller to read abool— so the first symptom was every craft output going to the default container, which looks exactly like a router that installed fine and had no opinion. The refusal now names the type and says what was not done. No behaviour change: the same routers are accepted and refused as before. -
Crafting — the output router's "decided, but delegating" answer is documented as unobservable. Crafting.
ICraftingOutputRouterdocumentstrueas "the router made a routing decision", and the shippedOutputToContainerRouterleaned on the distinction in its own remarks: returningtruewith a null target supposedly meant "a routing decision, delegating the final destination". The service reads the return value and the name together, so that answer and a flatfalsereach the same branch and the output goes to the default container either way. Documentation only — the behaviour is unchanged and now pinned by a test, so the wording cannot drift back into promising a distinction no caller can act on.
Fixed¶
Audit A remediation — the multiplayer, determinism and server claims. An audit enumerated 488 published claims about server-authoritative multiplayer, determinism and headless execution, tested the mechanism behind each, and found that the mechanisms largely hold while some of the sentences on top of them did not. Thirteen code fixes, nineteen wording corrections and thirty-nine new tests follow. The architectural conclusion is unchanged and is now said plainly: RevFramework is network adaptable, not network ready.
-
Loot — an award an authority refuses is no longer spawned into the world. Loot + Inventory / Currency. Delivery adapters answered with a bare
bool, so anIInventoryAuthorityorICurrencyAuthoritydenial was indistinguishable from a full bag — and a refused delivery falls back to the spawner, which consults no authority at all. So a correctly fail-closed gate did not stop the mutation; it moved it from a gated surface to an ungated one, where any player could pick the award up. Reachable without writing a denying binder, because an authority-wrapped Currency stack that cannot resolve an authority answersUnauthorizedby design. An authority denial now skips the spawner and reports throughUndelivered; every other refusal keeps the fallback the FAQ promises. Additive API:LootDeliveryRefusal,ILootInventoryRefusalReporterandILootCurrencyRefusalReporter— optional companions both shipped adapters implement. An adapter that implements neither behaves exactly as before. -
Loot — a delivery re-entered from inside an adapter no longer loses its own record. Loot.
LootService.Delivercleared six shared ledger buffers at its head and appended to them after each adapter call returned. A real inventory or wallet raises its change event synchronously from inside that call, so a subscriber that grants anything of its own re-enteredDelivermid-loop and wiped the ledger the outer call was still building. The outer call then announced the nested call's outcome as its own, and could returntrueon the strength of the nested delivery — whichLootPickupPayloadreads as "delivered" and destroys the pickup on. A nested delivery now takes its own ledger, exactly asRollhas done since the same defect was found one stage earlier. -
Loot — seeding per entity no longer collapses an authored table onto one bucket. Loot + Crafting. The seeded RNG assigned the caller's
intstraight into a 32-bit xorshift state. Xorshift expands a state badly andValue01reads the top 24 bits, so the first draw came out very nearly linear in the seed: across seeds 1–1024, every first draw was below 0.065. The obvious way to use the feature —UseDeterministicRng(chestId)with small sequential ids — therefore made a 70/20/9/1 table award its first bucket essentially always. The seed is now mixed through a SplitMix32 finalizer first. Same-seed reproducibility is unchanged; the numbers a given seed produces are not, which the published guarantee has always scoped to "within a build". -
Health — discrete regeneration is frame-rate independent, as its guarantees matrix said it was. Health.
TickDiscretereset its timer to zero after crossing the interval, discarding the overshoot, so the effective interval wasinterval + E[overshoot]— a function of frame time. It is the default mode. Measured at the defaults over 600 s: 595 heals at 120 fps against 590 at 60 fps, where 600 was due; on a 0.1 s interval at 15 fps, 449 against 600. The remainder is now carried, and capped at one interval so a stall cannot bank catch-up debt it discharges as a burst. -
Currency, Economy, Crafting and Core — a compensation that is itself refused is reported in a release build. A refund, a put-back, a rollback or a hold release can be refused, leaving the owner holding value the caller has just reported as not having happened. Five sites reported that only through
DevDiagnostics.WarnorEcoEditorBridge, both of which compile out of a release player — so the one failure a game most needs to know about was the one it could not see.CurrencyTxn,CurrencyHoldTxn,CompTxn,CraftingService's craft refund andCurrencyValueLedger's hold release now also report throughCompensationFailureReport, which is compiled into every build, as the six sibling sites already did. No result code changes. -
Currency — the awaiters' invalidation watcher no longer throws at the moment it is meant to answer. Currency.
WaitInvalidAsyncpolls its liveness check on a thread-pool continuation, and a Unity null check on a destroyed object reachesObject.GetInstanceID(), whose first act is a main-thread assertion. The documentedfalsestill came back — through a faulted task thatTask.WhenAnyhands over without rethrowing and nobody observes. The check is now written so it does not throw, and the catch is broad so the answer is right either way. -
Currency — a timeout longer than about 24.9 days is clamped rather than thrown. Currency.
TimeSpan.MaxValueis the obvious way to write "wait as long as it takes", and it was the one argument to all four awaiters that raisedArgumentOutOfRangeExceptionwhere every documented outcome is a return value. -
Currency —
CurrencyJsonSavecapture and restore agree on which object owns a wallet. Currency. Both halves resolved owners from an explicitly unordered scan, and then applied opposite tie-breaks: capture kept the last match, restore the first. With two objects sharing aStableId— which instantiating a prefab produces immediately — the balance captured from one could be restored to the other. Both now resolve throughStableIdOwners.Find<StableId>(), the total hierarchy order the save coordinator's currency participant already uses. -
Health —
AffinityRulere-collects its damage-affinity providers. Health. Providers were collected once inAwakeand never again, so one added at runtime was invisible for the object's lifetime and one destroyed kept answering from the cached array. It now refreshes onAwakeandOnEnableinto a kept list, matchingDamageRuleHubon the same GameObject, and skips a provider destroyed since the last refresh. -
Status Effects —
TimeModereports the clock that is driving, not the one that was asked for. Status Effects. SettingCustomwith a behaviour that does not implementITimeSource— or with none — silently fell back to scaled time whileTimeModewent on answeringCustom. It now answersScaled, and warns once per controller naming the type. The authored inspector value is untouched. The buyer this bites is the one wiring a server clock who implements Core'sITimeProviderinstead. -
Status Effects — a spreading burn cannot weaken a neighbour's existing burn. Status Effects. A spread burn is deliberately feeble and
BurnStatusstacks byReplace, so spreading onto an already-burning actor made them less on fire — and which actor it reached was physics-overlap order. A neighbour who is already burning is now skipped.Stackingis unchanged. -
Loot — a
chance01of NaN awards nothing. Loot. Every comparison against NaN is false, so a NaN chance fell through both explicit bounds checks and awarded unconditionally — the one input that defeated the bounds handling the roller was written to guarantee. -
Five doc comments that a buyer's own build would reject. An unclosed
<remarks>inHealthSystemand a bare&in four other files are CS1570 for anyone compiling withGenerateDocumentationFile, and a broken build for anyone treating warnings as errors. A structural gate now parses every shipped///block; writing it is what found the four the audit had not.
Published claims corrected against the tree, not softened. Each replaced a sentence the implementation does not support:
README.md, which ships in every package, said "Multiplayer-safe core pipelines" with no qualification, contradicted by a guarantees matrix in the same product on every available reading. Its Currency & Economy card credited Economy with authority-gated mutation; Economy has no authority interface and three of the framework's own pages say so.- The Health guarantees matrix published "Frame-rate independence ✔ (accumulator-based)" over the defect above, and the Status Effects matrix published "❌ Never (both rules are order-independent)" for aura potency — true of two zones, and not of three, because IEEE 754 multiplication is not associative. Both rows now say what holds, and where.
- Determinism is opt-in and the default is not deterministic, which no page said. The Loot and Crafting matrices now separate same-run, cross-process and cross-platform reproducibility and answer each.
authority.mdgained the Scope and Discovery columns a guarantees page had been promising, and now names Pickups as a loot delivery boundary and authority denial as a delivery outcome.- The Currency bootstrap README's remedy for two publishers was "publish once per scene", which in a two-scene process produces exactly the two publishes it warns about. The rule is once per process — the composition model is one simulated world per process — and the diagnostic for exceeding it is editor-only, which now also gets said.
-
Smaller corrections: the awaiters are not "without polling" (completion is event-driven, invalidation polls); a negative exchange fee is not a bonus (
TryQuoteclamps it to zero);MaxHealthModifierStackentries are not persisted;AttributeSet.RestoreSnapshotno longer claims every save participant is silent on restore. -
Health — an authority that has been switched off stops answering, even for a component that already asked. Health.
HealthSystembuilt a resolver closure over the authority it found and rebuilt it only when the scene cache was invalidated — which nothing but the shipped binder's enable and disable hooks does. A customIHealthAuthoritythat was disabled or destroyed after its first consultation therefore went on being invoked and went on giving its last answer, and a granting one went on granting through a gate the Inspector said was off. There was no way for its author to say otherwise: the cache and bothInvalidateoverloads are internal, andClearAuthorityResolver()only recovers from an authority that is already dead. The component now re-tests the instance it holds on every mutation, which is whatStatusEffectControllerandSceneInventoryServicehave always done. The shipped binder is unaffected.Reachable only with
Require Authorityticked and an authority you wrote yourself — the exact configuration the previous entry in this section made possible. -
Health — "nearest wins" is now true whatever resolved first. Health. The resolution order reads object, then parents, then the scene, and it is stated that way on five surfaces — but the scene cache was consulted before both hierarchy steps, and a hierarchy hit was written into it. So the promise held only until some other component in the scene resolved. After that, an actor carrying its own denying authority was never asked, and an actor carrying its own granting one leaked scene-wide to every actor that had none. The two hierarchy steps now run first and their answer is not cached, because it belongs to one object rather than to the scene; only the scene-wide steps cache.
PickupAuthoritymade exactly this change for exactly this reason in 1.2.0.Behaviour changes only in scenes carrying more than one usable
IHealthAuthority, and changes it to what every page describing the order already said.SetAuthorityResolverstill wins over everything resolved from the scene. -
Status Effects — a disabled binder on the actor no longer grants one last mutation. Status Effects. Three of the resolver's five steps refuse a disabled or inactive authority; the local and parent steps did not, and the controller consults what resolution returns before its own liveness check gets another look. So switching off a binder on the actor's own hierarchy bought one more granted mutation and one more ticked frame — repeatable, because any binder anywhere in the scene enabling, disabling or being destroyed re-opens the search. All five steps now apply one rule, and the parent walk continues past a switched-off binder to a live one above it.
-
Inventory —
RefreshAuthority()re-resolves instead of handing back what it already had. Inventory. The resolver keeps one authority for the whole process and returns it before it looks at anything else, so the only public re-resolution route there is re-resolved through that cache and returned the same instance every time. A denying authority added later — the CookbookLockdownis the shipped example — was invisible to a service that had already resolved, silently, for the rest of the session, including one placed on the service's own GameObject. The call now discards the cache first. Among scene-wide authorities the search still keeps the first one it finds, so put yours on the service or a parent if it has to outrank a binder already in the scene. -
Pickups & Status Effects — a status pickup the target refused is no longer reported as delivered. Pickups + Status Effects.
StatusApplyEffectdiscarded the result ofApplyOrRefreshand reported delivery whenever the target had aStatusEffectControllerat all. A status refused for immunity, or by an authority that denied the mutation, was therefore announced as delivered — and withDestroy On Useon, that is a pickup destroyed with nothing applied, which is the caseIPickupEffectReportsDeliveryexists to prevent. The item-use path raisedStatusUseEvents.StatusAppliedFromItemUsefor the same non-event. Both now follow the controller's answer. The effect's own remarks said this could not be reported becauseApplyOrRefreshreturnedvoid; that stopped being true when it began returning a result, and a call that ignores a return value compiles unchanged. -
Currency & Save — a refused restore no longer destroys the owner's escrow holds. Currency.
CurrencyPersistence.Restoreinvalidated open holds before it wrote anything, which put it ahead of every way the method can refuse: an authority denial, a Fail-mode cap policy, an invalid snapshot line. The holds were dropped without crediting, no balance was then written, and the drop happened before the batch was captured so nothing could undo it — the held money was gone, and the token reportedInvalidated, whose contract tells the caller its money is exactly where the save said it was and there is nothing to recover. Invalidation now happens once every line has been written. The anti-mint rule it exists for is unchanged: a successful restore still voids the holds before any release can credit an amount the restored balance already accounts for. -
Save — a load every participant refused is reported as unapplied, not partly applied. Save + Status Effects + Currency. Both participants marked the restore as having mutated something before attempting it, as a backstop for a customer hook that throws part-way. A section where every entry was refused — a denying authority, an immune target, a wallet a cap policy declined — was therefore filed
PartiallyApplied, which the report deliberately does not offer for carry-over, under a message reading "partly applied" or "Some wallets did restore" about a load that applied nothing. Both now mark on the answer: Status Effects when an entry reportsApplied, Currency when a wallet actually had lines written. The throw backstop is kept, scoped to a throw from inside the apply itself, so a factory that throws before touching a controller still leaves the section carryable and a hook that throws after mutating one still reports partial.CurrencyPersistence.Restoregained an overload reporting how many snapshot lines it wrote, which is what lets a caller tell a refusal that changed nothing from one that changed something. The existing overload is unchanged. -
Status Effects — a controller disabled and re-enabled resolves again. Status Effects.
OnEnableclears the authority the controller holds (to whatever the serialized provider slot carries, which is usually nothing) but left the "I already searched, at this epoch" memo pinned at an earlier success, and resolution only re-opens when the field is null and the epoch has moved. So a gated controller that was pooled — disabled and re-enabled, or its actor toggled — was stranded refusing every apply and every tick, silently, until some binder in the scene toggled orRefreshAuthority()was called. The memo is now cleared with the field it describes. -
Status Effects — a custom authority that leaves reopens the search. Status Effects. A destroyed or disabled
IStatusAuthoritywas dropped correctly and then never replaced, even by a liveStatusAuthorityBinderone step further down the resolution order, because only a binder moves the resolution epoch. The README, the guarantees matrix andRefreshAuthority's own remarks all say resolution reopens; that was true of the shipped binder and of nothing else. A drop now reopens it. Fails closed, so the cost was a controller refusing everything rather than granting it. -
Inventory — a null owner is
InvalidArgs, notNoAuthority. Inventory.SortandResizeContainerwent straight to the authority guard, which short-circuits on a null owner and fabricatesNoAuthority— "denied by inventory authority", about a decision nobody was asked to make. Every other mutation on the service answersInvalidArgsfor the same input, and with no authority in the scene a null owner was the only way to obtainNoAuthorityat all. -
Currency & Inventory — an inventory authority refusal reaching
ItemBackedCurrencyAdapterreportsUnauthorized. Currency + Inventory. Every non-partial inventory failure collapsed toCurOpCode.UnknownError, which that code's own documentation reserves for unexpected errors, so a refused player got "an unexpected error occurred" and a caller could not tell policy from bug. A missing container and a full bag were hidden by the same collapse and now map toNotFoundand their own codes. -
Pickups — a refused delivery no longer burns the actor's cooldown. Pickups.
PickupEffectstarted the per-owner cooldown before consulting the reporting seam, so an attempt the downstream system refused — an inventory or currency authority denying, a full bag, a status controller refusing, a decorator cancelling — opened the window for a payload that never landed. The actor came back once the refusal cleared and was refused again, by a cooldown for nothing, with nothing logged. Only bites when the cooldown is above its default of zero; thePricedPickuprecipe documented it as a caveat with "leave the cooldown at 0" as the workaround, and that caveat is now stale. -
Crafting —
SetMaxParallelJobsis gated with the station caps. Crafting. WithGate Scheduler Controlson, the four station-cap setters asked the authority and the global parallel-job cap did not — although it changes the broadest scheduler control there is and promotes queued jobs exactly as they do. The property name, the guarantees matrix and the binder's own remark all describe scheduler controls as one category; only the inspector tooltip narrowed it to station caps, and the code agreed with the tooltip. Affects projects that opted the gate on. -
Economy — a store that throws no longer keeps the buyer's money. Economy.
ShopService.Buyran its refunds and put-backs only on a returned failure, so anIItemStorethat threw — the case reached in practice through a customerIInventoryAuthoritywhose decision source is unavailable — walked past every compensation with the money leg already applied: charged, undelivered, no refund attempted, no report, and an exception where Economy's own page promises results. The same unwind now runs on an exception, which is then logged and reported as a result. -
Currency & Save — a refused load no longer forgets the replay window. Currency + Save. The idempotency window was cleared before the owner loop, ahead of every way a restore can refuse. After a refusal the balances were never put back but every remembered request id was forgotten, so a retried id applied a second time against a wallet that had not moved.
ICurrencyIdempotencyWindowsays not to clear for any reason other than a load that rewinds the state those ids describe. It is now cleared with the balances, and only when a balance moved. -
Health —
HasAuthorityResolvermeans injected, and only injected. Health. It reported any resolver, including one the component derived from the scene on its first gated mutation, while its own summary said "an explicit authority resolver has been injected". The CookbookLockdownreads it to leave alone a system whose resolver belongs to somebody else, and so skipped — with a warning — every listed actor that had simply been damaged once, leaving it outside the lockdown. A companionHasDerivedAuthorityreports the other half; the inspector reads both. -
Currency — publishing over a live override says so. Currency. There is one publish slot: a second
CurrencyBootstrap.Publishdisplaces the first, and whichever scope is disposed first empties it for both, so tearing down one bootstrap can leave another alive, enabled and no longer published, with every later mutation resolving to the raw ungated wallet. The semantics are deliberate and stay; what was missing was any signal, and a warning now names both stacks at the moment the second publish happens. The Bootstrap README documents the model and the disabled-bootstrap window alongside it. -
Status Effects —
ApplyOrRefresh(null)on a denied controller reportsNoAuthority. Status Effects. It tested the factory for null before the gate whileApplyStatusgates first, so one controller gave two answers to the same denial and told the caller their argument was the problem. The authority-first order is whatApplyStatuspublishes and a shipped test calls load-bearing. -
Core — the authority guard says which denial it is. All systems using
HealthSystem. With no resolver at all the warning reported that "HasAuthority() returned false", a call that had not happened, while the caller's ownLastAuthorityErrorcorrectly said no resolver was available. The two need different actions, and the console and the API now agree.
The guard's own remark is corrected in the same pass: it claimed the other systems "gate earlier, when they resolve an authority". They do not — every gated system refuses per mutation, and three of the five resolve inside that gate — so the premise was false when it was written. What is unique to Health is the warn-once log, which is the whole of this helper, and the trigger for a second consumer is restated accordingly. Nothing about the no-unification conclusion changes.
-
Authority documentation, audited end to end. Behaviour is unchanged by the entries in this block; several of them describe behaviour that was changed above.
authority.mdgains a "what happens when your authority throws" column — Currency converts an exception to a loggedUnauthorized, the other five let it propagate before any mutation, and the asymmetry had never been written down. It also states the interface count honestly (seven across six systems; Crafting carries two), that two systems can approve and refuse the same action and both be right, and which restore paths consult an authority and which rewind without asking.- Every authority interface now documents its throwing contract and how often one player action can ask it — a lethal hit cancelled by a totem asks Health twice, a spreading burn asks Status twice, an exchange asks Currency three times if it has to refund. Implement them as pure, repeatable predicates.
- Pickups: the Authority README published the pre-1.2.0 resolution model — cache first, hierarchy second, a
nullcached when nothing was found — none of which is true since the hierarchy step moved in front of the cache. Two code comments attributed a "binder per player" recommendation to a page that recommends one per scene. - Health: the Authority README read as though the binder had to sit on the
HealthSystem; the abstractions README told implementers not to assume scene-based resolution, which is the model every other Health page documents. - Crafting: three surfaces said preflight checks authority. It does not — authority is evaluated at
Enqueue— so a greenCanCraftcan still be refusedUnauthorized. The binder inspector now says the component gates nothing until it is assigned, because Crafting discovers nothing. - Save: the guarantees matrix carried an authority row for the Currency and Status participants and none for Health, Inventory or Crafting, whose restores deliberately consult nobody.
- Inventory: the guarantees matrix gains scope, disabled-authority and published-after rows its sibling matrices already had, and names
CharacterEquipmentand snapshot restore besideCharacterInventoryas the surfaces that write without the gate. - Economy:
PolicyBlockedis glossed — an authority refusal arrives as the same code as a cap or a floor — and the two shipped demo panels say that their serialized service field can only hold the raw wallet, so money moved through them is ungated by construction.
-
Health — a custom
IHealthAuthorityin the scene is now found. Health. The interface has been public since 1.0 and had no route by which your implementation of it could ever be called: every one of the resolver's five steps searched for the concreteHealthAuthorityBindercomponent rather than the interface, and the documented alternative,HealthSystem.SetAuthorityResolver, takes aFunc<bool>. So implementingIHealthAuthorityproduced dead code that looked exactly like working code — and withRequire Authorityticked and no binder present, the symptom was not an ungated object but a health system that silently refused every mutation while a perfectly good authority sat in the scene.Health was the only system doing this. Currency, Inventory, Pickups and Status Effects all resolve their authority by interface, and Crafting takes the component it is assigned; this puts Health on the same rule rather than inventing a new one. Discovery is nearest-first — the object itself, then its parents, then the scene — and the same active-and-enabled test applies to a custom implementation as to the shipped binder, so one that has been switched off falls through to a working one instead of becoming the scene's authority.
What this changes for you. Nothing, unless your project has a class implementing
IHealthAuthoritysitting in a scene — in which case it was being ignored and now answers.SetAuthorityResolverstill takes precedence over anything resolved from the scene, so a project that worked around this the documented way is unaffected. The shippedHealthAuthorityBinderresolves exactly as before. -
Status Effects — two effects called into a collaborator Unity had already destroyed. Status Effects.
VulnerabilityStatusandHasteStatuseach hold their sink through an interface —IDamageTakenModifierandICooldownScaleSink— and checked it with a plainsink != null. That is C#'s reference comparison, not Unity's overloaded one, and a destroyedMonoBehaviour's managed half is still alive, so the check passed and the effect called a component that no longer existed.
What that cost depended entirely on your sink's own body, which is why it went unnoticed for so long. A sink keeping its state in plain fields was called normally and the withdrawal landed nowhere — no exception, no warning, the multiplier simply never came back off anything. One that touches gameObject, transform or name threw MissingReferenceException out of the effect's Remove, and out of whatever was expiring the status. The loud case is the better one.
Both now skip a destroyed sink. There is nothing to withdraw from an object that is gone, and the Health fallback is deliberately not consulted instead — binding a sink is what stopped that route being used, and popping a contribution it never pushed would take one belonging to something else. SlowStatus never had this: its field is typed as the concrete MovementSpeedScaler, so its != really is Unity's operator.
Six regression tests, both shapes of sink, each verified to fail against the old check.
Internal¶
No runtime behaviour changes. The tests themselves land in the free owner download rather than in any package; recorded here because these items change what the release gates catch.
-
The public-API gate can see virtualness, parameter names and default values.
PublicApiRenderer.Mods()emitted visibility and static-ness only, so sealing a virtual method, renaming a parameter used as a named argument, and changing a default value all produced an identical golden — three breaking changes the gate was structurally unable to notice. Subclassing is an advertised extension route (PickupEffect.OnApply,InteractablePickupBase.DoPickup,RarityTheme.GetColor, the statusApply/Removepair), and there were 34 public or protected virtual and abstract members in the shipped runtime.Regenerating the goldens against the new renderer surfaced 121 inheritance modifiers and 171 default values that had never been recorded. Enum defaults render by name rather than as the underlying integer reflection hands back, because a golden reading
= 2where the source says= ProbeChoice.Thirdis a diff nobody can review. Interface members render bare on purpose: every method on an interface isabstract virtualin metadata, so the word would appear on every interface line in every golden and distinguish nothing.Eight tests drive the renderer through its real entry point against public probe types in the test assembly, which is not enrolled in the goldens. Proved rather than asserted: changing one default in
PickupPrefabSettings.Defaults2Dnow fails the gate with a diff naming the parameter, where before it was invisible. -
Property, invariant and state-machine coverage across eleven surfaces, measured against fourteen deliberate defects. Attributes, Crafting, Currency, Inventory, Loot, and the save coordinator. The existing suites are example-based: each proves the outcome of one arrangement. That leaves the space between the examples unproven, and this pass closes it where the space is worth exploring — arithmetic over a continuum, and hand-maintained collections that have to stay in step.
The point is not the forty new tests. It is that fourteen defects were deliberately introduced into shipping code, in four disjoint batches, each followed by a full EditMode run and reverted — and twelve of them were invisible to the suite as it stood. Among them:
- Loot. Moving every internal bucket boundary of the weighted pick by one entry passes the existing 20,000-sample convergence check and every other test in the tree. A convergence check proves the buckets are the right size; it can say nothing about where they are. Separately, deleting the correction for
Value01()'s inclusive upper bound makes quantities land one above the authored maximum, which the existing range assertion cannot see because its generator never returns exactly 1. - Currency. Switching the exchange quote from
MidpointRounding.AwayFromZerotoToEven— so every half-value of money rounds the other way — was a green build. Every existing Exchange fixture authorsroundDown: truewith a zero fee, so the branch the flag selects when it is off, which is the default, had no coverage at all. Also: making escrow's owner index drop the first token rather than the matching one leaves all nine existing Escrow fixtures green, because none of them ever has three live holds on one owner and settles the middle one. - Attributes. Swapping the ordinal comparer for the machine's default leaves the test whose name claims that property passing. Both ordering tests use ids drawn from
alpha,midandzeta, which sort identically under ordinal, culture-sensitive and case-insensitive comparison, so neither can tell the three apart. The order is what makes a save payload identical for identical state, and a culture-sensitive sort makes a save file depend on the player's locale. - Inventory. Making the all-or-nothing add's rollback undo only its last placement leaves the dedicated atomicity test green — that fixture's arrangement produces exactly one placement, so the loop it exists to guard never runs twice.
- Save. Dropping the blank-key guard from the coordinator's unrecognised sweep makes
Restorethrow on a hand-edited payload, and the 54-test coordinator suite stays green: no payload in it carries a blank-key section.
Two of the fourteen were already covered — a station-cap off-by-one in the crafting scheduler and a save section recorded in two buckets — and those fixtures say so in their own remarks rather than claiming a discrimination they do not have.
Every sweep is bounded by construction and deterministic: enumerated queue shapes rather than sampling, fixed seeds reported in the failure message, and the operation log printed alongside the state before and after, so a failure can be reproduced rather than stared at. The forty tests add 798 ms; EditMode execution did not move outside run-to-run variance.
- Two documented public promises had no test anywhere. Crafting, Loot.
LootService.UseDeterministicRng(int)andCraftingService.UseDeterministicRng(int)are public API on two shipped services, and both are sold on reproducibility in their own documentation — "the same seed replays the same rolls". Neither was called by a single test. The existing determinism coverage proves the roller is deterministic against a random source the test defines itself, which says nothing about the provider the product actually installs.
Both are now pinned as a relation — same seed, same run; different seeds, different runs; re-seeding rewinds — rather than as a literal sequence, so the generator's internals do not accidentally become a contract you could not change. The Crafting fixture rolls its chance outputs through the shipped AddChanceOutputModifier on the owner, which is the supported extension path, rather than through the internal hooks consumers are told not to use.
Nothing was broken: both promises held. What changes is that breaking them now costs a red build.
- Two behaviours found while sweeping, deliberately left alone. Neither contradicts anything the documentation says, so neither is a fix, and changing either would be a behaviour change on shipped software rather than a correction to it.
Status Effects — a non-finite magnitude passes every clamp. StatusRegistry bounds each built-in effect's magnitude on construction, but NaN compares false against every bound and so passes through all of them; the system contains no finiteness check anywhere. A NaN slow multiplier reaches MovementSpeedScaler. It cannot arrive from the inspector, which cannot author one — only from a magnitude your own code computes. Attributes refuses non-finite values explicitly and is the model if this is ever closed.
Currency — a clamped debit can raise a balance that is already below its floor. Under CapMode.Clamp, a wallet holding 5 against a floor of 10 answers a debit of 5 with Ok and a balance of 10. Reaching that state needs a write that bypasses the decorators, or a policy floor lowered at runtime; no path through a composed stack produces it, because every mutating operation clamps upward. Documented as written — the decorator clamps to bounds — and surprising as observed.
- The published test-suite figures are re-measured.
TESTING.mdsaid~102,000 lines of tests; the tree measures ~118,000, so the page now says so. The drift fence that guards that figure has a ±15% tolerance and it still passed, but only by 1.8%, and that page's argument is that its numbers are measured rather than remembered.
Migration¶
- Loot and Crafting seeded RNG produce different numbers for the same seed. The seed is now mixed through a SplitMix32 finalizer before it reaches the generator. Same-seed reproducibility within a build is unchanged — that is what the published guarantee has always scoped — but the values a given seed produces are not. If you tuned a table against the old behaviour, retune it: seeding per entity used to make a 70/20/9/1 table award its first bucket essentially always, so a table that looked correctly weighted in the inspector and generous in play is now weighted as authored. Tables driven by the default unseeded provider are unaffected.
- A usable item whose effect refuses is no longer consumed. Inventory. If an item carries more than one effect and any one of them can refuse — an
IUseEffectReportsDeliveryimplementer that reports a partial or failed delivery — the item now survives and the call returnsInvOpCode.Partial. It previously counted the siblings that delivered and consumed the item anyway. This changes behaviour with no compile error. Items with a single effect are unaffected, as are items whose effects never refuse, which is every effect that does not implement that interface. Effects that already delivered are not rolled back. - Saves written by this version will not load in an older build. All packages with save. Every shipped participant now stamps its section and writes it as version 2. Your existing saves still load — version 1 sections carry no stamp, are accepted exactly as before, and are upgraded the first time a save is rewritten by this build. But an older RevFramework refuses a version it does not understand, so rolling a build back after players have saved leaves those saves unreadable. Plan the rollback window before you ship, not after.
- A custom
IStatusEffectControllermust now returnStatusApplyResult. Status Effects. This is the only source-breaking change in the release.ApplyStatusandApplyOrRefreshchanged fromvoidto a returned result; callers that ignore the value still compile, which was measured across all 46 Cookbook recipes, every sample, integration and teaching panel. Only a project that wrote its own implementation of the interface is affected. The framework contains exactly one. InteractablePickupBaseenforces pickup authority itself. All packages. A subclass deliberately left ungated, in a project that has anIPickupAuthorityin reach, is now gated by it. If your project uses no pickup authority nothing changes —Resolvereturns null and the pickup proceeds. A subclass that already tested authority insideDoPickupcan drop that check, or leave it: it resolves twice and reaches the same answer. If one pickup must ignore the scene's authority, scope the authority rather than the pickup — a binder on that prefab or its parent answers for its own hierarchy only.IFacingProvideris now found on the actor's children. All packages. A provider on a rig or a sprite child was previously never found and the actor's transform answered instead, silently. If your actor has one down there and your facing checks were passing on the transform's answer, they now use the provider's. This is the fix; it is listed here because the old answer may be what your scenes were tuned against.- Health's discrete regeneration now hits the interval the inspector states. Health. It discarded the overshoot on every tick, so the effective interval was longer than authored and the gap grew as frame time did: at the defaults over 600 s, 590 heals at 60 fps where 600 were due; on a 0.1 s interval at 15 fps, 449 against 600. Regeneration is therefore faster than it was, most noticeably on short intervals and low frame rates. If you tuned a rate against the drift, it now heals what it says.
- A throwing event handler no longer reaches the caller. Inventory, Status Effects.
CharacterEquipment.OnEquipped/OnUnequippedand the public staticStatusUseEvents.RaiseAppliednow isolate each subscriber: a throwing one is logged and swallowed, order is preserved, and the subscribers behind it still run. If you relied on an exception propagating out of one of these emits to abort the operation that raised it, it no longer does. OnUnequippedis raised later than it was. Inventory. It now fires after the deferred containers flush, matchingOnEquipped. A handler that read container state during an unequip saw committed state whoseOnChangedevents had not yet fired; it now sees both. A throwing handler no longer unwinds through the disposals or hands the caller an exception for an unequip that succeeded.CritRule.SetRngsurvives reconfiguration, andSetRng(null)takes the override back. Health. An installed generator used to be silently replaced by the nextConfigureRng— fromOnEnableor any of the three policy setters — so on a pooled enemy the first fight reproduced and no fight after it did. It now persists. If you calledSetRng(null)expecting a no-op, it now clears the override and returns toRngPolicy.- A Loot award an authority refuses is no longer spawned into the world. Loot + Inventory / Currency. An authority denial now skips the spawner and reports through
Undelivered. Every other refusal — a full bag, a cap — keeps the world-drop fallback. If you were relying on the fallback to recover authority-denied awards, readUndeliveredinstead. - Health authority resolution asks the actor's own hierarchy first, and does not cache that answer. Health. Behaviour changes only in scenes carrying more than one usable
IHealthAuthority, and changes it to what every page describing the order already said.SetAuthorityResolverstill wins over everything resolved from the scene. - A disabled Status Effects binder on the actor no longer grants one last mutation. Status Effects. The local and parent resolution steps now refuse a disabled or inactive authority, as the other three already did, and the parent walk continues past a switched-off binder to a live one above it. Switching a binder off now takes effect immediately rather than after one more granted mutation and one more ticked frame.
SetMaxHealthClampNoDeathEventsis[Obsolete]. All packages. A compile warning, not a behaviour change, and the method is not going away. Its name claims it suppresses death events specifically; it emits nothing at all, max-change included. UseSetMaxHealthSilent(value, clampCurrent: true)— the same call, and it also offersclampCurrent: false.MaxHealthModifierStack.ClampMode.ClampNoDeathEventskeeps its member, because it is serialized and removing it would silently repoint every prefab holding its value.- Currency's audit reader resolves through a decorator stack. All packages with Currency.
CurrencyAudit.GetandTryGetwere a single cast, so a stack with a decorator above the audit layer — which every shipped escrow factory composes — returned empty while the layer underneath recorded everything. A hand-composed stack that returned empty may now return entries. The default bootstrap forwards the reader explicitly and was never affected. - "A restore raises no events" was only ever true of two participants out of six. All packages, no code change. Health and Attributes write state directly. Currency, Inventory, Status Effects and Crafting restore through their own public APIs, which raise on a write — including refresh FX, and including Crafting completing offline jobs mid-load. The advice is unchanged: reconcile from
RevSaveManager.LoadCompleted. What changes is what you may assume before it fires. If you treated the absence of an event as proof that no load was running, that was safe for two of the six and there is no framework-wide "a restore is running" signal to read instead — set a flag around your ownLoadcall.
[1.2.0] — 2026-08-16¶
Five strands: the new Loot system, RevSaveManager, a pass over the five dedicated debugger windows — Inventory, Crafting, Currency, Health and Status — a full remediation programme against an adversarial audit of the seven shipped systems, and the fixes from an independent verification of that programme's own result.
Loot ships in Inventory, Pickups & Crafting and in Complete. RevSaveManager adds public API: three types in Runtime/Core/Save/. The debugger work is editor tooling only. Everything else changes runtime behaviour in Economy, Inventory, Currency, Health, StatusEffects and Crafting.
Read the Migration section at the end even if nothing else here applies to you. Most of this release is correctness work, and a fix to something that behaved wrongly is still a change to what your project does today.
Added¶
HealthSystem.ClearDamageLocks()— releases every outstanding damage lock at once. The lock count is a plain counter with no owner and nothing reset it: notAwake,Revive,RestoreSnapshot, or disabling the component. ALockDamageScoped()whose holder was destroyed before disposing left an object permanently undamageable with nothing to call. Deliberately not wired into the lifecycle — holding a lock across a revive is legitimate, so recovering from a leak stays the caller's call.RechargeableShieldnow exposesOn Shield DamagedandOn Shield BrokenUnityEvents in the Inspector. Both siblings had them —CapacityShieldnine,OverhealShieldtwelve, this one zero — while all three raise the same two C# events.OverhealShield's own XML records the identical asymmetry as a defect that was fixed for it; the third shield was left out.DamageContext.IsPreview/HealContext.IsPreview— the explicit read-only evaluation mode. The framework sets it for previews and clears it for real hits, forcing both directions rather than trusting the caller, so a previewed context reused for a real hit cannot leave rules sitting out. A custom rule that holds state — charges, cooldowns, counters, RNG — should check it. Every rule RevFramework ships does; nothing in the framework can make yours.DamageRuleHub.WillRun(object)— whether a discovered rule would take part right now. Public so tooling can show which rules are sitting out, rather than keeping its own copy of the rule.DamageRuleHub.GetOrderedRules(...)/GetOrderedPostRules(...), and theHealRuleHubequivalents — the rules in the exact order they will execute, filled into a caller-supplied list. For tooling that needs to show or verify execution order without re-deriving it.IUseEffectReportsDelivery(Inventory) — the optional seam a use effect implements to report that its payload was refused. MirrorsIPickupEffectReportsDeliveryon the Pickups side, including its default: an effect that does not implement it is assumed to have applied.OverhealShield.AddTemp(int)now returns the points actually added after clamping —0when the shield was already full. Previously it returned nothing, so a caller could not tell a full grant from one the shield absorbed entirely. Existing calls that ignore the return are unaffected.RevSaveRestore.MarkMutated()— how a save participant tells the coordinator it has begun changing live state, so a later failure is classified as a partial restore rather than a refusal. See the Save PublicAPI page.StatusEffectController.RefreshAuthority()— re-resolves the authority that gates the controller, for a project whose authority is published after the scene's controllers exist. MatchesSceneInventoryService.RefreshAuthority, which Inventory has always shipped. A destroyed or disabled authority is dropped automatically and needs no call.CraftingService.SetAuthority(ICraftingAuthority)andCraftingService.SetCurrencyAdapter(ICraftingCurrencyAdapter)— swap or clear one seam without touching the others.SetOutputRouteralready existed; these complete the set now thatConfigurepreserves what you omit.HealthStatusHooks.ReflectPushSink/ReflectPopSink, withTryPushReflectandPopReflect— the seam through which damage reflection reaches Thorns. Status Effects cannot name Health's rule types without giving up the compiles-without-Health guarantee, so the Health integration installs the route and the status asks for it. Same shape as theTickSinkbeside it.TryPushReflectreturns whether anything was installed, which is how a caller knows it owes a matching pop.StatusApplyEffect.Create(StatusEffectDefinitionBase)— theScriptableObject-correct way to build one. The constructor still works and is unchanged.InventorySizeSyncis now public and on the Add Component menu. It wasinternalwhile its own README told buyers to attach it.EscrowOpCode.InvalidatedandICurrencyEscrowSnapshotAware— a hold dropped because its owner's wallet was restored. A custom escrow only participates if it implements the interface; one that does not is left alone, and the restore logs that it could not invalidate rather than assuming it had.CurrencyResolve.PublishEpoch— changes whenever the published currency stack is published, replaced or cleared. Anything that caches the result ofServiceFromshould compare it and re-resolve when it differs; resolving per call needs nothing. See the Currency PublicAPI page.CurOpCode.Partial/EcoOpCode.Partial— part of the requested amount was applied and the rest was not.Successisfalseby design, as it already is forInvOpCode.Partial. The one failure code that reports a mutation which happened: do not retry it, read the balances.InventoryRestoreReportand anInventorySnapshots.ApplyJsonoverload that returns one — how many saved items were named, resolved, substituted and skipped. Theboolsays whether the snapshot was readable; the report says whether anything in it survived.-
CompensationFailureReport(Core) — a refund, put-back or rollback that was itself refused now has a signal that survives a release build. Every one of those sites reported throughDevDiagnosticsor Economy's editor bridge, and both compile out of a shipped player, so the one failure a game most needs to know about was the one it could not see. Subscribe toFailed, or checkAny/Lastat a checkpoint. It is a diagnostic channel and not a recovery mechanism: no result code changes, and ignoring it leaves behaviour exactly as it was. -
Loot — a new system. Weighted drop tables that roll pure and deliver through the systems you already have. A
LootTableasset describes what can drop and how often.LootRollerturns one into aLootResultgiven anIRandomProvider, and does nothing else — it touches no scene, no owner and no other system. Delivery is separate, and lives in define-gated integrations for Inventory, Currency, Pickups and Health.
That split is the design, not an implementation detail. Drop rates are unit-testable with no Play Mode, the assembly keeps compiling with Inventory deleted, and the teaching panels demonstrate rolling with nothing else installed. Items travel as GUID strings — the same decoupling Crafting.Core.ItemRef uses, for the same reason.
Two roll models per table. Weighted takes N picks by relative weight and always yields N; IndependentChance tests each entry separately and may yield nothing. Tables nest, with a depth cap and path-based cycle detection — the same table reached down two different branches is legitimate, and only a table reaching itself is a cycle.
Every award is accounted for. Each one is delivered to the owner, spawned into the world, or named in the Undelivered event — never quietly dropped, which is the failure a loot system is most likely to hide. A full bag falls back to a world drop where a spawner is bound, so the award is recoverable rather than lost. Granted reports what actually reached the player, not what the table produced; use the return of RollAndGrant for that.
LootDropOnDeath wires the flagship case. LootDebuggerWindow samples a table's real distribution so authored weights can be checked against what they do. Two teaching panels and a Quickstart scene ship with it, and the documentation set matches every other system's.
- Docs — a stated convention for view components that watch a service and draw what they see.
Documentation/ViewComponentscollects the rules that four separate defects each broke a different part of: whatOnDisablemust clear, whyOnEnablehas to subscribe even when nothing changed, why subscribing twice must be impossible, why priming by replaying events only works from empty, and why two components that differ only in renderer must offer the same controls.
Written because CurrencyBar, InventoryBinder and StatusBuffBar each produced a hide-and-show bug with a different cause, and each was written carefully. Ships in every package, since every package has UI in this shape.
- Currency —
CurrencyBarnow has the Auto Rebind toggleCurrencyBarTMPalready had. Both bars run the same throttled probe fromUpdateto notice aSceneCurrencyServiceappearing or swapping at runtime; only the TMP one could be told to stop. A scene whose service is wired once at startup can now switch the probe off on either bar.
Defaults to on, so nothing changes unless it is unticked. The two components differ only in which text component they drive, and a customer choosing between them on that basis should not silently lose an option.
- Health —
OverhealShieldnow has Inspector event hooks, matchingCapacityShield. On Shield Damaged (carrying the temporary HP consumed) and On Shield Broken sit beside the existing C# events rather than replacing them, and fire from all three break paths — absorption, the decay tick, and a downward clamp of the maximum.
CapacityShield has offered designer hooks since 1.0 and this shield never did, so the same scene could wire one from the Inspector and not the other. Both hooks are invoked through the same guard as the C# events, so a throwing hook cannot abort absorption, swallow the break event, or escape into the damage pipeline that called in.
- Pickups + Health —
TempShieldEffect, a pickup that grants temporary shield points through Health'sOverhealShield. Create it from RevFramework ▸ Integrations ▸ Pickups ▸ Health ▸ Effects ▸ Temp Shield, with a matchingTempShieldPickupDefinition. It lives in the Health integration assembly, so Pickups remains deletable without Health.
There are now two shield pickups, and they are not interchangeable. Core Pickups already ships ShieldEffect, backed by its own self-contained ShieldSystem with no Health dependency — keep using that one unless you specifically want the granted points inside the Health damage pipeline. Use this one when the target already has an OverhealShield and the points should decay, absorb and interact with damage exactly like any other overheal. Applying both to one actor gives it two independent shield pools.
Two behaviours worth knowing before you place one. The shield is looked up on the resolved host object only, not through its parents, so a pickup whose collider sits on a child will not find a shield mounted on the actor root. And the grant is clamped by the shield's own MaxTemp, so less than the configured amount may land and a full shield absorbs the pickup for nothing — that is the shield's rule, and the effect reports no failure for it.
- Core —
RevSaveManager, so a save is a call rather than a component you write first.RevSaveCoordinatoris deliberately a router: it takes and returns a string and has no opinion about where that string lives, who contributes to it, or when. Those opinions still had to live somewhere, and until now that somewhere was every project separately. Add the component, register your participants, callSave("slot1").
The part worth having even if you would have written the rest yourself is carryOver, wired. After a load the manager keeps the sections nobody claimed — plus any a participant refused without applying anything — and feeds them into the next save. Without it, loading a Complete-era save in a project with Crafting removed and saving again discards the Crafting data permanently. It is a parameter on Capture that nobody passes, because you have to read the coordinator's remarks to learn it exists. PartiallyApplied sections are deliberately excluded: part of one is live state already, so writing it back later would overwrite the owners that did load.
LoadCompleted is where presentation gets reconciled. Participants restore state without emitting the events that state would normally raise — Health writes current, max and dead state directly, so no Died, no Revived, no HealthChanged. That silence is correct, since firing death events during a load would spawn VFX and drop loot every time a save was read, but it leaves anything bound to those events stale. The event fires whether or not the load succeeded.
It cannot discover the framework's own participants for you, and that is structural. They live in define-gated assemblies under Integrations/Save/ which reference Core; referencing them back would invert the dependency graph. Register the five you use, once. Yours sit alongside them with no privilege difference.
- Core —
IRevSaveStoreandFileSaveStore. Saves go toApplication.persistentDataPath/Savesout of the box, behind an interface you can replace with cloud storage, PlayerPrefs, or an encrypted blob by assigningRevSaveManager.Store.
Two behaviours worth knowing. Writes go through a temporary file and a move, so a process killed mid-write leaves the previous save readable rather than a truncated one — a player force-quitting during an autosave is not a rare event. And slot names are validated rather than sanitised: a slot becomes a file name, so one containing a path separator or a relative segment is refused and reported. Quietly rewriting it into something safe would mean the caller's slot and the file no longer correspond, which surfaces much later as a load that silently finds nothing.
- 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.RevSaveOrdersuppliesEarly,DefaultandLateso 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.
-
Status Effects —
Open Status Debuggeron theStatusEffectControllercontext menu. Right-click the component header and the debugger opens already bound to that controller. The editor docs had described this entry for some time; it had never actually shipped. -
Health — the Preview tab now says when an Edit Mode preview is not the whole story.
HealthSystemresolves its rule hubs and shield inAwake, which Unity does not run outside Play Mode, so a preview taken in Edit Mode goes through neither while the Rules and Shields tabs still list them. The tab now warns, and only when the selected target actually has rules or shields for the preview to miss. -
Samples — a sample scene now tells you why it is doing nothing. Opening one checks it for missing scripts and, when it finds them, names the objects and the packages responsible. 34 of the 42 sample scenes carry component references to the Input System and 9 to Universal RP; without those packages Unity cannot load the component, the
EventSystemends up with no input module, and pressing Play does nothing at all — with nothing in the Console to say why. This release documents that too, under Fixed. Documentation alone is the wrong instrument for it, because the README sends a new user to a sample scene as their first step — so they meet the symptom before the sentence explaining it.
The check reads the scene rather than the package list: it looks for the missing scripts themselves, so a package you do not have but that scene does not need stays quiet, and a scene broken for some other reason is still reported. Once per scene per session, sample scenes only, and it changes nothing. Tools ▸ RevGaming ▸ RevFramework ▸ Validate ▸ Check Sample Scene Dependencies runs the same check on demand.
- Onboarding — the per-SKU welcome windows now link the onboarding video series. It was linked from the Complete window and nowhere else, which was backwards: the series covers what the framework is and what it deliberately is not, and a single-SKU buyer is the one most likely to need that. All four windows now carry it, from one shared URL so the four cannot drift apart.
Changed¶
- The cross-cutting guide pages know Loot exists. Loot shipped into
Documentation/, the teaching panels, the samples and the docs nav, but four pages that describe the framework as a whole never learned about it: the dependency contract, the authority guide, the editor entry points, and the Inventory, Pickups & Crafting SKU page. A buyer reading any of them to work out what they had would not have found it.
Each now says something specific rather than adding the word "Loot" to a list. The dependency page explains that rolling is pure and only delivery needs other systems. The authority guide gives Loot a row saying it has no authority model at all — rolling mutates nothing, so the question is answered at the delivery boundary where Inventory and Currency apply theirs. The editor page notes that the Loot debugger does something the others do not: it samples a table and reports the distribution it actually produces, with no Play Mode needed.
- Input — the default input service no longer allocates on every frame it is polled.
UnityInputServicereads the Input System through reflection so the framework does not depend on that package.PropertyInfo.GetValuereturnsobject, so every key state and every stick value came back boxed — steady per-frame garbage in any build left on that path, of the kind that shows up as periodic collection spikes on low-end targets rather than as a fault you can point at. The reflected members are now read through typed accessors built once, which return their values directly.
Separately, GetAxisRaw answers one axis per call, so a controller reading Horizontal and then Vertical — which is what the shipped sample pawn does — ran the whole eight-key sweep plus a stick read twice per frame. Input cannot change between two calls in the same frame, so the second is now served from the first.
No behaviour change: the same values are returned, and any platform that will not build a typed accessor falls back to the previous reflection path rather than losing input. This affects the built-in default only; if you replaced IInputService with your own, nothing here applies to you.
-
Crafting — a priced recipe crafted with no currency adapter now says so, once. Crafting without currency is supported and those recipes craft free; that is unchanged and still documented. What was missing was any way to tell the supported setup apart from a forgotten adapter, since both look identical from the outside. A recipe carrying a real cost, crafted with nothing to charge, now reports it once per service in the editor and in development builds. A project that simply does not use currency stays silent, and release builds are unaffected.
-
Status Effects — the tick at the end of a damage-over-time is now documented, not altered. A DoT's last step is paid in full even when only a fraction of its duration remained, so on a frame hitch a burn can deal slightly more than its rate times its duration. Duration bounds how long an effect lives, not how much it delivers. That was never written down anywhere, and nothing tested it, so it could have changed silently in a refactor and moved every project's damage totals. It is now stated on
TimedStatusEffect.Tickand pinned by a test. The behaviour itself is unchanged — this is the same readingRegenOverTimeEffectalready makes when its interval does not divide its duration evenly, and the overshoot is bounded in practice by Unity's ownmaximumDeltaTime. -
Inventory — a split raises one change notification, whichever placement it used.
SplitResultbatched its notifications when placing automatically and not when given an explicit target slot, so the same call raised one delta or two depending on an argument. The reason to batch is not symmetry: the target is credited before the source is debited, so notifying per slot handed a subscriber a container that momentarily held more than it should, and a subscriber is free to read totals — or re-enter — on that notification. The single delta still describes both slots, so nothing is lost; only the count changes. If you were counting notifications rather than reading their contents, expect one where you previously saw two. -
Currency — the transfer preview no longer reads a cap mode it does not use.
TryComputeEffectiveTransferAmountpulledCapModeout of every rule and never consulted it, which read as though the mode were being considered. Transfer semantics are unchanged and deliberate: source-side clamping still succeeds with the reduced amount, destination caps still discard the overflow, and only "nothing is transferable" fails. -
Economy — two pieces of misleading documentation corrected.
RewardServicedocumented that money is not rolled back on a partial grant but said nothing about items, which are not rolled back either — making a deliberate choice look like an oversight. And a rollback diagnostic reportedFirstIdxwhile the loops run in reverse, so the number was the highest failing index and pointed a reader at the wrong end of the batch. -
Economy — a purchase or craft that could not refund said nothing.
ShopService.Buyand the EconomyCraftingServicecharge before they deliver, and refund when a later step fails. All four refund sites discarded the result, so a refund the ledger refused — a cap, an authority, a policy — left the owner having paid for something they were told did not happen, with only the original error reaching the caller.
All four now report what could not be refunded. Still best-effort, and the returned code is unchanged: the transaction really did fail, and nothing here can force a refused refund through.
- Economy — diagnostics were invisible in development builds. Economy logged through
EcoEditorBridge, which compiled only underUNITY_EDITORand was an explicit no-op in every player build. Every other system usesDevDiagnostics, which is active in the editor and in development builds. So a development build — the one used to playtest and profile — reported problems from Currency, Crafting and Core while Economy stayed silent, including every rollback failure, which is the only place those are ever surfaced.
Economy's runtime diagnostics now match the rest of the framework. Reason-string canonicalisation warnings stay editor-only by design: they are an authoring concern, and their code path builds a canonical set by reflection that has no business in a player build.
- Packaging — the Complete package was missing a file every other package shipped.
Editor/README.mdshipped in all three system SKUs and in no Complete export. Complete lists Editor's sub-folders individually, so a file sitting directly underEditor/was covered by none of them. Complete is sold as the superset of the three, and nothing enforced that: the existing manifest test checks only that declared paths resolve on disk, so a path present in a SKU list and absent from Complete's was invisible — both exports succeeded and the customer who paid most got less.
Found by reading an exported package by hand during release verification. A new test now asserts Complete covers every path each SKU declares, so the next one is caught automatically.
- Inventory — a rebound view redrew once for every time it had ever been bound.
InventoryBinder.Subscribehad no duplicate guard andBindnever detached first, so any path that rebound without an interveningOnDisableattached another handler.SetOwneris the reachable one — it is public and is how a party or character-swap UI repoints a view — so three owner swaps left four handlers attached, and a single container change ranRefreshfour times along with four service lookups. A subclass whose redraw appends rather than rebuilds would show its contents multiplied.
The binder now tracks which service its handler is attached to and attaches at most once.
- Inventory — a service that had been switched off was still handed out. Scene-scoped resolution matched disabled components, so disabling
SceneInventoryService— which clears its ownInstanceinOnDisable— did not take it out of circulation: callers resolved the very service that had just withdrawn.
Resolution now skips a disabled component. It deliberately still returns a service on a GameObject that is not active yet: that service has withdrawn nothing, its containers are field-initialised and its authority check defaults permissive, so it works and callers resolving before activation have always relied on getting it. The search also keeps looking past an unusable candidate instead of ending there.
- Pickups — a reusable trigger pickup switched off mid-re-arm never fired again.
TriggerPickuplatches on consume so a multi-collider actor cannot take it twice in one physics step, and cleared that latch from aWaitForFixedUpdatecoroutine. Unity kills a coroutine when its host is disabled, so a pickup withdestroyOnUseoff that was deactivated inside that one-step window came back still latched and was dead permanently.
That is the same stranding the surrounding try/finally was written to prevent — consumed, never destroyed, never re-armed — reached by a different route. The latch is now the physics step of the last consume, compared rather than cleared, so the next step re-arms the pickup whether or not it was alive to see it. Same-step protection is unchanged.
- Status Effects — the buff bar showed wrong stack counts and stale icons after being hidden.
StatusBuffBarprimes itself on enable by running its applied-handler for every active status, and that handler increments a stack count — so it only produces the right answer against empty state. Disable cleared pending destroys but left the counts and icons in place, so priming added to them rather than rebuilding.
Two consequences, both from the same cause: every hide and show added one to the count of every still-active status, and a status that ended while the bar was hidden kept its icon, because the removal event arrived while nothing was subscribed. Hiding a HUD panel is enough to trigger either.
Icons and counts are now cleared on both edges, so the bar rebuilds from the controller instead of adding to whatever it kept.
- Currency — the affordability helpers reported a cost as payable when the wallet could not be read.
GetDeficitreturns an empty list to mean "fully affordable", and returned exactly that for a null service or an invalid owner. SoTryGetDeficitansweredtrueandFirstShortfallreported no shortfall precisely when nothing could be checked — a shop gating its buy button on either would enable the purchase.CurrencyPurchase.CanAffordalready refused on the same inputs, so the two affordability checks in the system disagreed.
An unreadable wallet now counts as empty, so the whole cost is reported as outstanding. Callers that never passed a null service or a destroyed owner are unaffected.
-
Currency — the convenience transfer preview ignored asymmetric rules.
ComputeEffectiveTransferAmounttook noITransferPolicyProviderand forwarded none, so it always answered under symmetric policy — the same name and purpose as theTry…overload it delegates to, quietly disagreeing with it whenever source and destination rules differed. It now accepts and forwards the provider. The parameter is optional and omitting it keeps the previous behaviour. -
Currency — a currency bar stopped updating after being hidden and shown again. All three bars — UGUI, TextMeshPro and UI Toolkit — released their wallet subscription in
OnDisablebut only re-took it inOnEnablewhen the resolved service differed from the one already held. After a plain hide and show it does not differ, so the bar came back holding a service reference with nothing listening, and displayed a frozen value from then on. Toggling a HUD panel is enough to trigger it.
The per-frame rebind probe could not repair it, because it applies the same test: once the held and resolved services agree, nothing re-subscribes. That is also why the same asymmetry in RefreshNow — which bound the service without subscribing — went unnoticed. Both now subscribe unconditionally; the call was already a no-op when the binding was current, so the condition only ever suppressed recovery.
-
Currency — the UI Toolkit bar drew the whole sprite sheet instead of the icon.
CurrencyBarUITKassignedSprite.texture, which is the entire source texture, so an icon taken from a sheet — or one the sprite packer had moved into an atlas — rendered whole, ignoring its rect and pivot. It now assigns the sprite itself, whichUnityEngine.UIElements.Imagesupports directly. Atlas packing differs between the editor and a player build, so this could look correct in play mode and be wrong once shipped. -
Currency — a missing authority made every wallet mutation scan the whole scene. Authority resolution cached only successes, so a scene where discovery found nothing re-ran the full pipeline — ending in a scene-wide
FindObjectsByType— on every Credit, Debit, Set and Transfer. Discovery only accepts enabled components, so simply disabling the authority was enough to reach it, and every one of those operations was going to be refused anyway.
An empty result is now remembered for the remainder of the frame, so the scan happens at most once per scene per frame. It is deliberately not cached outright: an authority enabled later must still be found, and a sticky miss would leave every mutation permanently unauthorized — much worse than the cost it saves. CurrencyAuthorityBinder now clears the cache as it is enabled or disabled, so toggling the built-in authority takes effect immediately rather than on the next frame.
Discovery also no longer stops at the first unusable candidate. A disabled authority on the context object, or in an earlier scene root, used to end that stage of the search and push the caller into the wider scan, which then found the enabled one anyway.
Behaviour is unchanged in the editor outside play mode, where there is no frame counter to key the memo to.
-
Currency —
ICurrencyAuthoritydid not say that transfers ignoreHasAuthority. A transfer consultsHasAuthorityTransferonly and never falls back to the per-operation check. That is deliberate — transfer policy is often asymmetric — but it means freezing a wallet by denyingCreditdoes not stop that wallet being credited by a transfer. The interface now states that each method is the sole gate for the operations it covers, and calls out the trap directly. No behaviour changed. -
Currency — the coroutine balance awaiter leaked its wallet subscription.
CurrencyAwaiters.WaitForBalanceAtLeastsubscribed toOnWalletChangedand unsubscribed after its wait loop. Unity stops a coroutine when the behaviour that started it is destroyed, and the code after the lastyieldnever runs — so a wait interrupted that way left the handler attached to a long-lived service permanently, holding the owner and service alive and running on every wallet change from then on.
A finally does not help: Unity does not dispose a stopped coroutine's enumerator, so it never runs either. The coroutine now polls the balance once per frame instead. It already yielded every frame, so this costs nothing, and it cannot leak because it never registers anything. The async variants are unaffected — they are plain async methods with real finally semantics.
One behavioural difference: a balance that rises past the target and falls back within a single frame is no longer observed by the coroutine variant.
The host parameter, previously accepted and never used, now ends the wait if the host dies. That matters when the coroutine is started on a different behaviour, since Unity only stops a coroutine when the object that started it goes away. Passing null keeps the previous behaviour exactly.
- Crafting, Currency, Economy and Core — refunds and rollbacks that failed said nothing. A sweep for one pattern across four systems: a compensating action — a refund, a rollback, an escrow release — whose result was discarded, or whose exception was swallowed. Each one left state inconsistent with the outcome the caller had just been handed, and every symptom was invisible.
Thirteen sites in five files. Crafting could not return inputs to a full or filtering container and the items were simply lost. TableCurrencyExchange and CurrencyPurchase could fail to refund a bundle they were reporting as failed. The Economy ledger could leave escrow holds standing while reporting a clean failure. CompTxn swallowed a throwing undo entirely.
All of them now report what could not be undone, naming the token or item so it can be retried. Every one stays best-effort and no returned code changes: nothing here can force a refused refund through, and the operations really did fail. What changed is that the inconsistency is no longer silent.
- Currency — a transaction reversal that could not be applied was silently swallowed.
CurrencyTxnrolls back on failure, but the reversal actions discarded their results and the loop swallowed exceptions. When the stack refused a reversal — a policy cap or an authority denying the reverse operation — the wallet was left part-way through a transaction thatCommithad just reported as failed, and nothing said so. The caller was told the transaction did not happen; part of it had.
Reversals now report what they could not undo, per step and in summary. Rollback is still best-effort and Commit still returns the original failure: nothing can force a refused reversal through, and changing the returned code would be a different decision. What changed is that the inconsistency is no longer invisible.
- Currency — a hold transaction that could not unwind said nothing.
CurrencyHoldTxndiscarded the results of both its credit reversals and its escrow releases, and swallowed exceptions from each. The escrow half matters most: a hold whose refund is refused is deliberately kept so the funds stay recoverable, and dropping that result made the recovery undiscoverable — the transaction reported failure while the player's money sat debited against a token nobody was holding.
Both now report what could not be unwound, per step and in summary, naming the token so it can be released again. Still best-effort, and the returned failure is unchanged.
- Currency — a refused escrow refund destroyed the money instead of keeping it recoverable.
TryHolddebits immediately, so every hold is money already out of the wallet. Returning it is a credit, and a credit can be refused — a wallet sitting at aCapMode.Failcap is enough.
Release removed the hold before attempting the refund, so a refusal returned a failure with the token already gone: nothing left to retry against, and the held amount simply lost. ExpireStale was worse — it discarded the refund result entirely, so a refused refund removed the hold, returned no error, and counted it as successfully expired.
Both now refund first and only forget the hold once the money is actually back. A refused refund keeps the hold, so releasing again once the wallet has room returns the funds, and reports once per hold rather than on every expiry pass. A CapMode.Clamp cap that quietly refunds less than was held is now reported too; its outcome is unchanged, because callers under Clamp have always seen release succeed and changing that is a decision rather than a fix.
- Currency — reusing a request id for a different amount reported success without moving any money. The idempotency decorator keyed replays on kind, currency and request id, but not the amount. A second call carrying the same id and a different amount therefore matched, short-circuited to success, and never reached the wallet — so a shop could hand over goods for a charge that was never made, with the earlier smaller charge standing in its place.
The amount is now part of the key, and a reused id with a different amount is refused with the new CurOpCode.IdempotencyMismatch rather than replayed. Genuine retries — same id, same amount — still replay exactly as before and still never apply twice.
Economy solved this first and named it EcoOpCode.IdempotencyMismatch; Currency now matches. If you switch on CurOpCode without a default branch, this is a new value to handle.
- Crafting — the 3D workbench searched the whole scene every physics step.
CraftingWorkbench3Dresolved the tagged player insideOnTriggerStay, before the cooldown and key-press checks that discard almost every call. Standing next to a bench was enough to pay for it — a scene-wideFindGameObjectWithTagper physics step, per collider inside the trigger, with no key pressed and nothing to craft. Prefer Tagged Player Owner is on by default, so this was the out-of-the-box path.
The cheap gates now run first. Owner resolution still happens before anything that shows feedback or changes state, which is what the original ordering protected, and the repeat cooldown is now set only once the collider is confirmed to be the player's — previously a crate sitting in the same trigger could consume it. The 2D workbench never had this: its only tagged-player lookup is in an editor-only test method.
- Crafting — cancelling a job from a cancellation handler could cancel the wrong one, silently.
CancelJoblooked up the job's position, raisedOnJobCancelled, and then removed by that position. A handler that cancelled another job shifted every later index, so the removal took a different job than the one asked for — the requested craft carried on running while an unrelated one vanished, with no error. Where the shift ran past the end it threw instead.
The completion tick had the same shape: it raised the completed and failed events before removing by index, and its backwards walk could also read past the end once a listener had removed several jobs. All four sites now remove by identity, which no amount of shifting can misdirect, and the completion loop re-checks its bounds each iteration. Cancelling siblings from a cancellation handler — "cancel the rest of this order" — is ordinary, so this needed no unusual setup to hit.
- Crafting — a failed input reservation commit no longer leaves the inputs reserved. The escrow path released everything except the reservation that had just failed to commit, so an adapter refusing there left the inputs held with nothing able to free them — materials a player can see and cannot use.
Releaseis documented idempotent and safe afterTryCommit, so the call is correct even where a commit failed part-way.
The output-commit branch deliberately still releases nothing, and now says why. By that point the inputs are committed and the currency captured, so the service is mid-transaction with an adapter that has broken its own atomicity contract, and the escrow path's stated rule applies: it does not provide rollback across broken adapter contracts. Both halves are now pinned by tests so the asymmetry reads as a decision.
-
Crafting — a delivery rollback that could not reclaim an item said nothing. When one output add fails, the earlier adds are consumed back; those results were discarded, so a rollback that could not take an item back left the player holding part of a craft reported as failed — free items, no error, and a caller told the delivery had not happened. Reachable when an add fires inventory events a listener reacts to by moving the item. Nothing can force the item back, so it is now reported per item and in summary rather than pretending the rollback was total.
-
Crafting — the authority resolver's one-time warning fires once per play session again.
CraftingAuthorityResolverwas the only static in Crafting with no load-time reset, so with domain reload disabled its warn-once became warn-once-per-Editor-session: misconfigure the authority, miss the warning, and every later play session was silent. It now resets alongsideRecipeCacheandRecipeResolve. -
Crafting —
CraftJobSnapshot.completionTxnIddocumented that idempotent completion was not enforced. It is. Restore skips a snapshot whose id has already been applied, and every completion path records its id. The remark said the opposite, which would have sent implementers off to build dedup they already had. It now also states the scope: one service instance, held in memory, so it dedups replays within a session and a fresh session correctly starts empty. -
Crafting — a crafting modifier that threw could freeze crafting permanently.
ICraftingModifiercomponents were called without exception isolation, while service-registered modifier hooks had it. Adjustments are computed for every finished job before it leaves the job list, so one throwing modifier escaped the entire tick — and because the job stayed finished, every later frame hit the same throw. Completions and queue promotion were dead for the lifetime of the service, not just for a frame.
Components are now isolated individually, as registered hooks always were: one that throws is logged and skipped, and the rest still apply. ICraftingValidator components and OnValidateCraft subscribers had the same unguarded shape and are isolated too — per validator rather than per chain, so a broken hook cannot silently suppress the validators behind it and turn a gate permissive.
- Crafting — cancelling a job from a progress listener threw out of the crafting tick.
CraftingServicewalked its job list against a length captured before it started raising progress events. Cancelling removes the job immediately, so that length outlived the list and the next index ran off the end.
The exception escaped the whole tick, not just the progress pass — so completions and queue promotion were skipped for that frame: finished crafts did not finish and queued crafts did not start. It recovered on the following frame, unless the listener cancelled every frame, in which case crafting stalled for good while filling the console.
Reachable from ordinary use — "cancel this craft when the player walks away" is a normal thing to hang off the progress event. The list length is now re-read around every callback. If the list does shrink mid-pass the round-robin can skip or revisit one entry, costing a single job one progress callback for one tick, which self-corrects on the next.
- Crafting — the escrow path now says when the inventory adapter simply does not do reservations.
TryCraftImmediateEscrowreportedConsumeInputsFailed, which is indistinguishable from genuinely not having the materials — so the first thing you saw when trying the escrow path with the shipped stack was "consuming inputs failed" while looking at a full container.
The shipped InventoryCraftingAdapter implements the reservation seam and declines every call, because Inventory exposes no atomic reserve surface. That passes the escrow path's interface check and fails at the first reservation instead. A console warning now names the adapter and the real cause. The failure reason is deliberately unchanged: the service cannot tell an adapter that declined once from one that never supports reservations, and CraftFailReason is public API that a developer-time wiring problem does not justify widening.
-
Health —
Revivenow documents that it can raiseRevivedwithout a precedingDied. An extra-life handler cancels death by callingRevivefrom inside the before-death seam, beforeDiedwould have been raised — and cancelling means it never is. Anything pairing the two events therefore sees a revive with no opening death. The behaviour is unchanged and is deliberate: the alternatives are suppressing a revive that really happened, or reordering lifecycle events in a shipped system.Revivealso clears the last damage report, which is now stated on it too, since it means a "you were saved from…" screen reading the report after a rescue finds nothing. -
Health — a before-death handler that cancels death without restoring health now says so. The target is left alive on zero health, and because dead state is a stored flag rather than a function of health it then passes the is-it-dead check on every later hit and has each one rejected, since the applied amount is clamped to the health remaining. The result is a character that can neither die nor take damage, indefinitely, until something restores health or calls
Kill()— a state with no visible cause.
IBeforeDeathHandler already documents that the implementer is responsible for restoring state, and the shipped ExtraLifeTotemHandler does. What was missing was any indication when a custom handler does not. This is a warning, not a correction: clamping to one hit point would overrule a handler that meant to leave the target at zero.
- Inventory —
InventoryBinderdid all the work of binding a view and then threw it away. The component resolved the service, bound the owner, subscribed toOnContainerChanged, filtered each delta down to the owner and canonicalised container it was watching, re-fetched on change and unsubscribed cleanly on disable — and then handed that to aprivateRefresh()with an empty body. Private, so a subclass could not override it; and with_containerprivate and no accessor, a subclass had nothing to draw from either. Attaching it or deriving from it produced no output and offered no way in. It failed by doing nothing, which is why nothing caught it: the component is referenced by no scene, prefab or sample, and its tests cover the binding half they can observe.
Refresh() is now protected virtual, with Container and Service exposed as protected properties — Service typed as IInventoryService rather than the concrete component, so a view performing a move or a split goes through the surface that enforces authority and returns a reason for a refusal.
Two behaviour changes come with it. A failed rebind now clears Container and calls Refresh() instead of silently leaving the previous owner's container in place, which is the one stale state a view cannot detect for itself — no delta arrives for a container nobody is watching, so a subclass would have carried on drawing items belonging to someone it was no longer bound to. And Awake, OnEnable, OnDisable and OnDestroy are now protected virtual rather than private: declared private, a subclass writing its own void Awake() hides the base rather than extending it, Unity invokes only the derived method, and binding never runs — the same silent-nothing failure by a different route.
No existing project can be affected: the component had no observable behaviour to depend on.
-
Currency — a service that was disabled and re-enabled stopped being the registered one.
SceneCurrencyServicereleased its singleton pointer inOnDisableand only ever claimed it inAwake, 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.
ItemStackis a struct, but itsmetais aListreference, so a plain assignment copies the value fields and shares the metadata. Three paths stored a caller's stack directly —InventoryContainer.SetStackAtResult,EquipmentContainer.SetSlotExactandEquipmentContainer.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 noOnChangedraised and nothing observing the change.InventoryContainer.PeekStackAtleaked 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.
-
Health — typing
-or=in a debugger text field no longer deals damage. The window's hotkeys consumed those keystrokes before any field saw them, so a negative Flat Delta or a Source Id containing a dash could not be entered while in Play Mode. -
Currency — the audit Currency filter matched the wrong currency, and could throw, when the currency list contained an empty slot or had shrunk since the filter was last used.
-
Crafting — the cooldown tool's
Seed Remaining (s)field can be edited. It reset to 1 on every repaint, so no other value could be entered. -
Editor docs corrected. The entry-point guide advertised a
Window ▸ RevFramework ▸ Debuggerssubmenu that does not exist, the Status Effects tools listed a menu path that was never right, and the Inventory tools described a "safe fallback" whenREV_INVENTORY_PRESENTis undefined — the assembly simply does not compile without it, so the tools are absent rather than stubbed. -
Health —
SetMaxHealthClampNoDeathEventsandSetMaxHealthSilentwere two copies of the same four statements, and now share one. Behaviour is unchanged and both remain public. The duplication mattered becauseMaxHealthModifierStackroutes to both from a serializedClampModeenum, 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 —
Moneynow documents that it cannot be serialized, because its[Serializable]attribute suggests otherwise.amountisreadonly, and both Unity's serializer andJsonUtilityskip readonly fields, so aMoneywritten out carries no value and reads back asMoney.Zero. Persistmoney.amountas alongand rebuild withnew Money(saved)— which is whatWalletSnapshotLineandCurrencyJsonSavealready do. The attribute is deliberately left in place: removing it would change how any existing[SerializeField] Moneyin a project behaves. -
All five debuggers now live at
Window ▸ RevFramework ▸ <System> ▸ Debugger. Crafting, Currency and Status previously sat at the top level asCrafting Debugger,Currency DebuggerandStatus Debugger. If you have a bookmark or a habit, this is the one change here you will notice. -
Crafting and Currency debuggers refresh while Play Mode runs. Both only repainted when the pointer happened to be over them, which froze Crafting's job progress bars and remaining-time readouts, and Currency's escrow expiry countdown.
-
The Crafting debugger scrolls. Its lower panels — Jobs, RNG, Cooldown, Status — were unreachable on a short window.
Removed¶
Integrations/Inventory/CurrencyIntegration/is gone. It shipped an assembly containing oneinternalhelper that nothing called and that nothing outside its own assembly could call, under a README describing an Inventory ↔ Currency bridge it did not implement. The real bridge isIntegrations/Currency/InventoryIntegration/, whereItemBackedCurrencyAdapterbacks a wallet with inventory items, and it is untouched.
Nothing referenced the removed assembly and it exposed no public type, so there is nothing to migrate. Integrations/Inventory/README.md now points at the real bridge instead of listing a second, emptier place to look for the same feature.
Fixed¶
-
Tests — four "reports nothing" controls asserted nothing at all. They relied on an unexpected warning failing the run; one even said so in a comment. Unity's
LogAssertfails a test on unexpected errors, assertions and exceptions — never on warnings. All four now callLogAssert.NoUnexpectedReceived(), and arming the Crafting refund control immediately caught a warning that test had never noticed it was emitting. -
Teaching — the Inventory panels no longer teach that the service is stateless. Two panels told the reader "the service stays stateless between operations". It does not: it holds the containers it has been asked about, and they accumulate for the lifetime of the scene. A buyer who believes the panel will never think to look there when memory grows.
-
Docs — the Pickups guarantees matrix links to the cross-system authority reference. The comparison table existed and no Pickups page pointed at it.
-
Docs — the framework no longer denies a headline feature it ships. Six places said, in different words, that transactional crafting does not work: the
InventoryCraftingAdapterclass XML ("the current Inventory stack does not expose reservation primitives, so reservation calls fail safely"), three READMEs ("input reservations always fail safely", "no atomic or escrow crafting is provided", "hold, capture and release are not implemented"), and a test's own rationale ("the shipped adapter never reserves"). All of it is false.InventoryCraftingAdapter.TryReserveInputsconsumes the inputs, sums quantities for repeated item ids, and gives every line back if any line cannot be taken;CurrencyHoldCraftingAdapterimplements hold, capture and release against the Currency escrow. An integrator reading any of those pages would have concluded the feature did not exist and built around it. The real boundary is duration, not capability: escrow refuses a craft whose adjusted duration is above zero, because a reservation outliving the call would need persisting across save/restore. -
Docs — "a failed craft never leaves a partial set of outputs behind" is now qualified. The rollback gives items back through the adapter, so it depends on that adapter and its container accepting them — and
CraftingService's own refund documentation says failures may leave partial state. The Crafting Mental Model and FAQ now say the same thing about escrow: its guarantee is reserve-by-doing plus compensation, conditional on the adapter invariants it already states as requirements. -
Docs — four rows of the Inventory guarantees matrix stated absolutes the code does not hold. "Partial operations signaled ✔", "Silent failure ❌ Never", "Event emission timing ✔ After mutation completes", "Mutations routed through service ✔" and "Authority evaluated before mutation ✔" are now qualified, with a section explaining each:
Movecan move part of a stack and reportOk(documented at the method in ~30 lines of XML and pinned by a test),CharacterInventorywrites directly and so is not authority-gated, and at least one emitter fires part-way through an operation. In each case the behaviour is deliberate and the matrix was the thing that was wrong. -
Status Effects — a pooled actor no longer keeps an aura's scaling forever.
GetControllerHostusedGetComponentInChildrenwithoutincludeInactive, so when a pooled actor was deactivated while still inside a zone the exit path could not find the host it had found on entry. The withdrawal was skipped and the zone's scaling stayed on that actor for the rest of the session — reappearing on whatever the pool spawned from it next. -
Docs — the Status Effects Teaching and Samples READMEs describe what ships. The Teaching README advertised two panels that have never existed (
StatusMovementCcPanel,StatusShieldsThornsPanel), omittedStatusStackingPolicyPanel, which does exist, and named a define guard (REV_STATUS_EFFECTS_PRESENT) that this framework does not define anywhere — the asmdef usesREV_TEACHABLES+REV_STATUS_PRESENT. The Samples README listed scenes "00–05" when four ship, with three of the four names wrong. The Health integration README ended mid-word, and now finishes its sentence. -
Docs — the Auras "Known limitations" no longer states the opposite of the guarantee. Its first bullet said overlapping zones do not stack, that the last zone to configure an actor wins, and that the first zone to leave drops everyone's contribution. All three were true of an earlier implementation and none is true now: contributions are held per zone and multiply, and a departing zone withdraws only its own. A real limitation is documented in its place — disabling and re-enabling a zone component does not restore the aura for an actor that never left the volume.
-
Status Effects — an effect whose
Removethrows no longer stays tracked and ticking. TheIStatusEffectcallbacks were the one customer seam with no isolation: the controller guards every event per subscriber and the factory with try/catch, butApply,TickandRemovewere invoked bare.Removeruns before the untrack, so a throw escaped mid-way and left a torn-down effect in the active list — reproducing exactly the corruption theSafeInvokeone line later exists to prevent — and escaped the publicvoidmutators with the collections inconsistent. Nothing inIStatusEffect's documentation, any README, or the matrix's non-guarantees ever said customer effect code must not throw. -
Status Effects — every immunity provider is consulted.
BlockedByImmunityusedTryGetComponent, which returns only the first match, so a provider answering "not immune" short-circuited the question and every later provider's answer was silently discarded. A correctly configured immunity could be nullified by an unrelated provider that merely sat earlier in the component list. Potency and resistance already aggregated across all providers; immunity now matches, and any provider saying "immune" wins. -
Status Effects —
Replaceleaves exactly one instance.RemoveStatusCoreiterates downward and has already untracked by the time it raises its removal notifications, so a listener that synchronously re-applies the same id inserts at an index the loop has passed. The loop exited without seeing it andReplacethen added the incoming effect unconditionally — two instances under the one rule that guarantees one. -
Status Effects — a refresh no longer pulses FX on the wrong effect. Three refresh paths captured a list index, ran the customer math service and two events, then re-read
_active[idx]for the context they reported. Any listener removing a status synchronously made that read land on a different effect, or throw. The effect is now held by reference across the whole block. -
Status Effects — one collider leaving no longer strips a multi-collider actor's whole aura contribution.
StatusAuraZonetracked colliders but keyed the provider contribution per zone with no refcount, so on an ordinary actor — body capsule plus a child weapon or hitbox collider — the firstOnTriggerExitwithdrew everything while the actor was still standing in the volume, and nothing restored it. It now refcounts per controller host. Defaults offered no protection:layerMaskis~0andrequiredTagis empty. -
Status Effects — an aura provider is no longer destroyed out from under another zone. A departing zone destroyed the provider when it was the last contributor; Unity flushes pending destroys between physics steps, so another zone's enter dispatched in the same step got the doomed component back and wrote its contribution into it, to be discarded at the flush — that zone's scaling then silently absent until the actor left and re-entered. Empty providers are now left attached, which is neutral: a provider holding no contributions returns a factor of 1.
-
Status Effects — the aura's Vulnerability sample can apply vulnerability.
vulnAmountcarried[Range(0, 1)]whileVulnerabilityStatusclamps withMathf.Max(1f, multiplier). The two ranges were disjoint, so every value the inspector permitted collapsed to exactly 1.0 — a 1× damage-taken multiplier. It was the one sample field whose permitted range disagreed with its consumer's. See the migration note. -
Status Effects — aura zones now tell
StatusProviderAggregatorto rebuild. The aggregator caches its provider list atOnEnableand documentsRebuild()as the way to tell it about a change; zones added and removed provider components at runtime and never called it. In the opt-in configuration where bothDefaultStatusMathServiceandStatusProviderAggregatorsit on the controller host, potency resolved against a stale cache and aura zones silently did nothing. A disabled aggregator also used to win resolution, becauseTryGetComponentdoes not filter onenabled. The shipped default was never affected. -
Docs —
BurnStatus's empty spread mask means every layer, and now says so. A zero-valuedspreadLayersis rewritten to~0, so a designer who leaves spread enabled and sets Spread Layers to "Nothing" gets spread everywhere — the inverse of the authored intent — while the tooltip said "Layer mask restricting spread targets". The convention was stated nowhere. It is not being changed:Can Spreadis the dedicated, documented way to turn spreading off, and redefining the empty mask would silently stop spread working for anyone relying on the default. -
Status Effects — turning off "Enable Global FX" no longer disables Stun, Haste and Vulnerability.
__InvokeApplyFXreturns before calling theOnApplyFXhook when the target's controller has that toggle unticked, and three shipped effects put their entire gameplay in that hook:HasteStatusbound the cooldown sink there,VulnerabilityStatusthe damage-taken sink, andStunStatus's hook was one line —DisableMatching(target)— which is the stun. The toggle's own tooltip recommends it for server-only objects, so a buyer following that advice got three statuses that were present, ticking and dispellable, and completely inert. All three now bind inApply, followingSlowStatusandShieldStatus, whose XML already states the rule: gameplay is not presentation. Removal was never gated, so nothing leaked while this was broken — it failed as a clean no-op. -
Status Effects — walking into an aura zone no longer resets every unrelated status timer.
StatusAuraZone.refreshOnEnterdefaulted to true, and a duration recompute is a full refresh:RecomputeDurationsForAllcallsRefresh(Duration × scale), which sets time remaining. So dropping aStatusAuraZonein from the component menu and walking an actor through it reset every active status on that actor to full duration — a poison one second from expiring became a full-length poison. It now defaults to false, matchingrefreshOnExit. The behaviour is still available; it is a choice rather than a default. See the migration note. -
Status Effects — removal is by reference, not by index. Every removal path captured a list index, then called
effect.Remove(gameObject)— customer code — and only then calledRemoveAt(i). A buyer effect whoseRemoveremoved another status at a lower index shifted the list, soRemoveAt(i)dropped the wrong element.TeardownAllwas worse: it indexed the live list while removing, so the same re-entry threwArgumentOutOfRangeExceptionout ofOnDisable/OnDestroy, leaving the controller half torn down. It now iterates a snapshot and isolates each teardown, because that is the last chance an effect gets to release what it bound. Not reachable with any shipped effect — it needs a buyer-authoredIStatusEffect, which is exactly what the extension-point documentation recommends writing. -
Docs — the Status Effects FAQ no longer tells you to break your project. Its "safe to delete" table marked six folders deletable; three of them —
Timing,Authority,Movement— are unconditional compile errors, because everything underRuntime/Systems/StatusEffectsis one assembly with no gating inside it. The closing line, "Only Abstractions and Core are required", was false. The table now says which folders are genuinely removable and explains that whole systems are the unit of optionality, not folders within one. -
Health — a revived enemy drops loot again.
LootDropOnDeathreset its once-per-life guard on enable and through the publicResetDropGuard, but never on revive, and the component subscribed toDiedwithout ever subscribing toRevived. So an enemy with an extra-life totem — or any revive mechanic — died, dropped, revived, died again and dropped nothing.ResetDropGuard's own summary says it exists "so a revived or pooled object can drop again", which read as though revive was already handled. -
Health —
ShieldDebuggerno longer ships as a missing script. It is aMonoBehavioura designer attaches to a scene object, and it lived underEditor/Gizmos/— a folder no asmdef covers, so Unity compiled it into the predefinedAssembly-CSharp-Editor, and any script under a folder namedEditoris editor-only. It worked perfectly while authoring and then logged a missing script error per instance when the scene loaded in a player build. It now lives in the Health runtime assembly with its drawing behind#if UNITY_EDITOR. Moved with its.meta, so the script GUID is unchanged and existing scenes keep their reference — a build that was already broken is repaired by the update. -
Health — the rejection reason players actually see is no longer the raw token "Other". Every PRE-rule cancellation — an armour rule setting
Cancelled, a team rule refusing friendly fire, any custom rule returningfalse— arrives asDamageRejectionReason.Other, and it was the one reasonHealthReasonTextleft unmapped. Five Teaching panels printed the enum name at the player. -
Health —
MaxChangedraises the designer UnityEvent before the C# event, matching the other five emitters in the component and the orderDocumentation/README.mdstates for the system. It was the only one the other way round, and the test the documentation cites as pinning the order coveredonDamageTakenalone. -
Health — a crit chance of 0 can no longer crit.
CritRulecomparedroll <= chanceagainst an RNG documented as[0, 1), andUnityEngine.Random.valueis inclusive of 0 — so the one roll that can come up exactly at the bound crit through a window that should be empty. -
Health — a throwing listener on a shield event no longer starves the listeners behind it.
OverhealShield,RechargeableShieldandShieldPoolwrapped the whole multicast delegate in a singletry/catch. A multicast delegate runs its subscribers in one call, so the first that threw abandoned every subscriber after it.ShieldPool's comment claimed parity withHealthSystem, which has isolated per listener all along. -
Health — the rule-count performance sample measures rule count.
HealthRulePerfSmokebuilt its buckets with N passthrough rules and never attached aDamageRuleHub, so the damage path skipped the rule stage entirely and every bucket — 0, 1, 5, 10 and 20 rules — measured identical work. A buyer profiling "should I worry about rule count?" was told it was free. It would also have hidden the per-hit rule-array allocations fixed above. -
Health — the dead branch in the authority denial message is gone.
AuthorityGuard.CanMutatereturns true immediately when authority is not required, so the!requireAuthoritydenial branch below it was unreachable and its wording described a state that cannot occur. -
Health — "Initialize To Max On Awake" can be turned off without producing a corpse. With the flag off,
Awaketakes the serializedcurrentHealthand setsisDead = currentHealth <= 0. The only place the inspector showed that field was inside a disabled scope under "Debug / State", so a designer could read the value and not set it: it stayed at the default of zero and the object awoke dead. The escapes were editing the YAML by hand or callingSetCurrentfrom code, neither discoverable from the tickbox that caused it. An editable Starting Health slider now appears in Core when the flag is off, with a warning if it is left at zero. -
Health — a heal no longer subtracts health, and no longer spills more than it was worth.
SetMaxHealth(value, clampCurrent: false)is a documented, supported option that legitimately leavesCurrentaboveMax— a temporary max buff expiring is the ordinary way to get there. The heal clamped toMin(before + amount, Max)with no lower bound, so the nextTryHealpulled health down to max, raising no damage events and leaving nothing in the game able to attribute the loss. The same arithmetic then madeappliednegative, andoverflow = amount - appliedcame out larger than the heal: in the regression test, a heal of 10 spilled 110 into the temp shield. -
Health —
LastDamageReport.Requestedreports what was requested. The field is documented as "the requested damage amount before rule, multiplier, or shield adjustments", and was read off the post-rule context.ArmorRulerewritesctx.RawAmountin place, so the framework's own most common mitigation rule made the field under-report every incoming hit — in exactly the field a damage meter reads to show damage before mitigation. -
Health — a before-death handler added at runtime is now consulted. The handler array was re-fetched only when it was null or empty, so a second extra-life totem granted to an object that already had one never fired.
RefreshOptionalComponents's own documentation said these handlers "re-fetch themselves on demand and never needed this" — and calling that very method was the only escape. The set is now re-read immediately before the seam runs, and the documentation says what is actually true. -
Health — a throwing collaborator no longer swallows the revive announcement. The invincibility and regeneration calls in
Revivesit between a committed state change and its announcement: health and the death flags are already written when they run. An exception there meantRevivednever fired, so the target came back alive with nothing told — every listener that re-enables AI, restores a collider or clears a death screen stayed in the dead state. This is not a change of policy on single-collaborator seams, which stay unguarded by design; it is narrower, and applies where an announcement is pending behind the call. -
Health — unticking a rule component now turns it off. The rule hubs discovered rules with
GetComponents, which includes disabled components, and then ran every one of them without consultingenabled. None of the six shipped PRE rules self-checks, so a designer untickingArmorRuleto look at unmitigated damage still got mitigation. The POST stage did honour the tickbox — each shipped POST rule testsisActiveAndEnabledon its own way in — so the two halves of one pipeline disagreed about what the tickbox meant. Both stages now skip rules that are disabled or sit on an inactive GameObject, and re-ticking one takes effect on the next evaluation with no refresh. See the migration note. -
Health — a damage preview no longer disagrees with the hit it is predicting. The mutation path reached rules through a property that resolved the hub on demand and refreshed it; preview was handed the raw backing field with neither. A rule hub added after
Awake, or a rule added after the hub, was therefore used by the real hit and invisible to preview — the number on screen said one thing and the hit did another. Both paths now go through one resolver. (The existing preview tests could not reach this: every one of them added the hub first, invokedAwakeby reflection and calledRefresh()by hand, performing exactly the two steps production omits.) -
Health — previewing damage no longer consumes the crit roll, and previewing a heal no longer advances
UnityEngine.Random. Preview runs the live rule components, which is what makes it agree with the real hit, but nothing told them it was a preview.CritRuledrew from its RNG on every preview: previews of the same hit disagreed with each other, seeded runs stopped reproducing, and a HUD that previewed each frame reshaped the crit stream at whatever rate it repainted — the Editor and Teaching panels preview on every IMGUI event.AntiHealRuledid the same againstUnityEngine.Random, which is one sequence the entire project shares, so a heal preview was perturbing every other system that draws from it. Both now sit out the roll during a preview and report the deterministic outcome; a forced crit still previews as a crit. -
Health — the rule-order inspectors no longer show an order the hub will not use. The hubs sort stably, deliberately: their XML explains that
Array.Sort's introsort only preserves component order for sixteen elements or fewer and reorders equal priorities freely above that. Both inspectors mirrored the ordering withArray.Sort— so a hub with more than sixteen rules and any tie displayed a different execution order than it used, in the one tool whose entire job is showing rule order. They now ask the hub. They also mark rules that are skipped because they are disabled. -
Health — a landed hit no longer allocates six arrays.
Refresh()rebuilt both rule arrays withGetComponents<T>()on every call, and the damage path called it three times per hit (twice from one condition that dereferenced the resolving property twice, once more from the post-damage notification) — defeating the cache the hub exists to provide. The hubs now fill pooled lists, so a steady-state hit allocates nothing there, and neither processor resolves the hub more than once per stage. -
Pickups — a refused payload no longer destroys the pickup, through any amount of wrapping. A decorator or a composite ran the effect it wrapped and reported a clean delivery whatever the effect actually said. Since a pickup uses that answer to decide whether to destroy itself, wrapping an item grant in any decorator — a sound, a VFX, a debug log — was enough to make the item vanish on collection into a full bag. The apply path is sealed on the decorator base, so there was no way to work around it in your own code.
What changes for you: a pickup whose payload was refused now stays in the world, and the actor can come back for it. A "collected!" sound or VFX in AfterApply no longer plays over a payload that was refused, and cancelling in BeforeApply now reports a refusal rather than a success.
A composite reports a refusal if any child refused. It keeps no record of which children already ran, so a composite holding two or more children that can each refuse may re-deliver a child that succeeded when a later attempt is made. That is deliberate and documented on the Pickups PublicAPI page: the alternative needs a per-actor key that Unity can reuse after collection, which would silently lose a payload for a different actor. Put payloads that must never re-deliver on the pickup component rather than inside a shared composite asset.
-
Pickups & Inventory —
UnifiedPickup2DinMode.Bothno longer re-delivers the half that succeeded. It tracked one outcome for two independent payloads, so an effect that applied alongside an item the bag refused correctly left the pickup in the world — and then re-applied the effect on every subsequent entry. Paired with a currency grant or a heal, a full bag turned the pickup into an unlimited source of it. Each half is now delivered at most once and the pickup is consumed only when every half it is configured to deliver has been. A reusable pickup (Destroy On Trigger off) re-arms exactly as before. -
Pickups & Health — a health, shield, regen, currency or status pickup collected by an actor that cannot receive it is no longer consumed. Five effects held the answer and threw it away: a health pickup walked over at full health, a temp-shield pickup at full shield, a currency pickup a cap or an authority refused, a status pickup with no
StatusEffectControlleron the target. All were destroyed as if they had worked. A toll plate built as a pickup with a negative currency amount let an under-funded player through and removed the obstacle permanently. -
Inventory — an item whose use-effects all refused is no longer consumed.
ItemUseSystemcounted every effect that ran as applied, so using a pouch whose contents the target container refused destroyed the pouch and its contents. The call now fails instead. Use effects written againstIUseEffectalone are unaffected: an effect with no way to report a refusal is still assumed to have applied. -
Pickups — prefabs produced by the authoring tools now work. All three routes shipped broken, none of them noisily. The trigger relay was added before the concrete collider, and the relay's
[RequireComponent]names an abstract collider type, which makes Unity'sAddComponentreturn null and add nothing — so the prefab had no relay and the pickup never fired, with an empty console. Two of the three routes also built the effect chain in memory and assigned it straight to the prefab, and a reference to an unsaved asset serialises as null — so the effect field was empty while the tool reported "Created prefab" and selected it. -
Pickups — a cooldown authored on a pickup definition now reaches the effect.
PickupEffect.cooldownDurationwas enforced at runtime and unreachable from any definition, so a pickup built through the tooling could not be given one.PickupEffectDefinitionBasegains Cooldown Seconds, defaulting to0— the behaviour every existing asset already has. -
Economy — selling several items at once no longer destroys the valid ones when a later line is bad.
Sellvalidated each item line inside the removal loop, so a stale line — an item consumed since the sell UI was built, which is the everyday way "sell all junk" goes wrong — returnedNotOwnedwith the earlier items already removed and destroyed. The seller was paid nothing and the caller got a refusal implying nothing had happened.Buyhas always run the identical two predicates as a preflight, in the same file. -
Currency —
CurrencyPurchase.TryGrantno longer leaves part of a failed grant applied. Same shape: it validated inside the credit loop, while its siblingTrySpendprechecked the identical condition twenty lines up. -
Currency — exchanging into a capped wallet no longer takes full payment for a short delivery. The source side of
TryExchangemeasured what was actually debited and refused a clamped exchange; the destination side did not, so a Clamp-mode ceiling could accept less than the quote, returnOk, and leave the owner having paid in full for less than they were quoted. Both legs are now unwound and the exchange refuses. Both assets ship wired together in the02_Currency_Exchangesample scene. -
Currency — a clamped escrow hold no longer destroys the money it took.
TryHoldcorrectly refuses to place a short hold and refunds the partial debit, but discarded that refund's result: a refused refund left the money gone, with no hold token to recover it by and no signal of any kind, while the caller saw a code that reads as "nothing was placed". -
Crafting and Loot — a currency refund or award that a cap only partly accepted is no longer reported as delivered in full. Both crafting adapters and the Loot currency adapter returned the service's
Successverbatim.ICraftingCurrencyCreditReporterstates in its own contract that a partial credit "counts as not applied"; Loot's documentation promises thatGrantedreports what actually reached the player. Neither was true under a Clamp-mode cap. A short credit now reports as not delivered, so a Loot award falls through to the world spawner or intoUndeliveredand stays recoverable. -
Inventory — a save whose items are all missing from the database no longer empties the inventory and reports success. Restore cleared the container before it resolved a single saved item, so a database that did not match the save — a patch that re-GUID'd items, the wrong database asset assigned, an index that was never built — emptied the player's bag while every layer reported a clean load. The next autosave made it permanent. Items are resolved first, and a snapshot in which nothing resolves is refused with the inventory untouched. Skipping some items is unchanged; that is what
MissingItemPolicy.Skipmeans. -
Currency — crafting, loot and the JSON save no longer run on an unprotected wallet. The scene bootstrap composes caps, audit, authority, idempotency and batch events and publishes from
Start(), but those three resolve inAwake/OnEnable, which Unity runs first — so they cached the raw wallet and kept it, while pickups in the same scene used the full stack. Two currency systems in one scene, with nothing saying so. They now notice the composed stack arriving.
This is a behaviour change, and the direction is stricter. A project that shipped with an authority binder set to deny, and working crafting, will find crafting begins to refuse — because the gate is now actually in the path. That is what the binder was asked to do.
-
Currency — re-enabling the bootstrap republishes. Disabling it cleared the published stack and nothing put it back, so toggling the component for a frame downgraded every later resolve to the raw scene service for the rest of the session.
-
Currency — an item-backed wallet no longer reports a half-completed transfer as an unknown error. The inner inventory returns
Partialafter the coins have moved; the adapter mapped that toUnknownErrorand returned before either wallet event was raised, so the coins moved, no event fired on either side, and a caller that retried moved them again. It now announces both balances and reports the newCurOpCode.Partial. Self-transfer is also guarded, as it already was onSceneCurrencyService. -
Economy —
Unauthorized,NotFoundandPartialfrom the currency layer no longer collapse toUnknownError. The mapping had no arm for any of them.Partialis the one that mattered: an Economy caller readingUnknownErrorwould roll back or retry an operation that had already half happened. -
Currency — an authority that throws is a denial, not an escape. A customer
ICurrencyAuthorityraising an exception left through the mutation entirely, past theCurOpResultcontract — and, because the save coordinator classifies on exceptions, out of a restore, where it was recorded as a section that applied nothing while earlier legs had already applied. It is now reported and treated as a refusal. -
Save — a load that fails partway is no longer recorded as "nothing happened". The coordinator decides between "this section is still a faithful record, carry it forward" and "part of this is live state now, do not" by looking at the exception. Any exception other than the framework's own sentinel meant the first — right for a version guard refusing a payload untouched, wrong for a participant that had already restored two owners and then hit something it did not expect. That section was carried into the next save on top of state that had already changed. Three of the framework's own participants had that hole independently.
A participant now declares the moment it starts changing things (RevSaveRestore.MarkMutated), and from that point any failure is classified as a partial restore. All five shipped participants declare it; StatusEffects additionally guards each entry, so one bad status costs one status rather than the rest of the section.
-
Currency — a quickload taken mid-hold no longer creates money. A hard escrow hold debits immediately, so a save taken while one is open records the reduced balance. Restoring it in the same process — a quickload, a checkpoint reload — put that balance back while the hold was still live, and releasing it afterwards credited the held amount on top of a balance that already accounted for it. Restoring now invalidates the owner's holds without crediting anything, because releasing is the credit that mints. A caller still holding a token gets the new
EscrowOpCode.Invalidated, which says the money is accounted for rather than lost. Loading in a fresh process was never affected and is unchanged. -
Currency — a save that names none of the wallets in this scene no longer zeroes them. Under the default
UnsavedWalletPolicy.Zero, the sweep that clears wallets a save does not mention ran even when the save matched nothing at all — so loading a save from a different scene, or one whose StableIds had changed, emptied every funded wallet in the level. A save that describes nothing here is not evidence about here. -
Crafting — an offline craft that cannot find its container now reports as failed. It refunded the currency, warned, and returned without raising the failure event its live equivalent and its own sibling branch both raise — so a UI waiting on that job never learned it had died, and the completion id was already spent, which makes a retry impossible.
-
Editor tooling — a relocated install no longer breaks its own tools. Everything except define sync assumed the framework lived at
Assets/RevFramework. Move the folder and the defines stayed correct, so every system compiled and looked healthy, while Pre-Build Clean reported "Nothing to clean." and the build then failed the teachables guard on the folder the clean had declined to see; the orphan scanner found no orphans however many there were; and the welcome windows decided no SKU was installed and greyed out their own Help menu items. -
Health, Status Effects & Inventory — an authority that is switched off or destroyed no longer keeps granting. All three held their resolved authority through an interface reference, which never reaches Unity's
==, so a destroyed binder answered from its managed fields forever. Health and Status Effects also treated a disabled binder as valid — Health's local and parent lookup steps cached one, making one actor's switched-off binder the authority for the whole scene, and Inventory re-resolved after a destroy but not after a disable. A gate that keeps answering after being switched off fails open, which is the worst direction for the one population that opted in.
What changes for you: disabling or destroying an authority binder now takes effect. If you were relying on a disabled binder still granting, enable it.
-
Health — a swapped authority now takes effect for components that already resolved one.
HealthSystembuilt a delegate over the binder it found the first time it needed one and never rebuilt it, so invalidating the authority cache — the documented way to swap an authority — changed nothing for anything already running. A host/client handover left every existing actor gated by the pre-handover binder. A resolver you install yourself withSetAuthorityResolveris still yours and is never rebuilt underneath you. -
Status Effects —
RecomputePotencyForAllis now authority-gated, like its sibling. It was the one public mutator on the controller without a gate, while the guarantee matrix states that effects do not mutate without authority. It is reached from default-on aura code on the line aboveRecomputeDurationsForAll, which is gated — so on a denied controller, walking into an aura zone rescaled every active effect's magnitude and left the durations alone. -
Status Effects — a gated controller with no authority no longer scans the project every frame. Only successful resolutions were remembered, and the check runs from
Updateahead of the "no active effects" early-out, so every gated controller ran a full discovery pass — including a project-wideFindObjectsByType— on every frame, whether or not it was carrying an effect. A temporarily disabled binder is a designed runtime state that put every controller in the scene into exactly that loop. A repeat miss is now an integer comparison; the search reopens whenever the authority cache is invalidated, which the shipped binder does on enable. -
Status Effects — a destroyed custom math service now falls back as documented. The liveness guard detected the destroyed service and then re-derived it from the same serialized field with a cast that does not use Unity's null operator, handing back the same dead object. The documented fallback could never run.
-
Health — a destroyed shield in a
ShieldChainno longer throws out of the damage pipeline. The chain holdsIShield[], so its null guard never saw a destroyed component and the next line asked it forisActiveAndEnabled, which throws — from insideTryAbsorb, mid-hit. The preview a health bar reads had the same defect. With Ignore Disabled Shields off it went further and called into the destroyed component. -
Crafting —
Configureno longer clears the seams you did not pass. Every argument after the inventory adapter is optional, and all three of currency, authority and router were assigned unconditionally — soConfigure(adapter, defaultContainer: "Backpack"), the natural way to change one thing, set the other three tonull. Every recipe with a cost then crafted free and every gate opened, silently. Runtime station caps set withSetStationCapwere discarded the same way.
What changes for you: omitting an argument now preserves the current value. To clear a seam deliberately, use SetAuthority(null), SetCurrencyAdapter(null) or SetOutputRouter(null).
-
Pickups — two actors with their own authority binders no longer share one answer. The first resolution step is a per-hierarchy lookup whose answer was written into a per-scene cache, and the cache was consulted first — so whichever pickup resolved first decided for the whole level. Local co-op, which the Authority page recommends by name, was broken: player two's pickups were gated by player one's binder.
-
Status Effects & Health — Thorns reflects damage again. It never did. The bridge that installs the reflection rule shipped in the Health integration with no callers anywhere, and was
internal, so you could not invoke it either. Meanwhile Thorns had an authoring asset with its own Create Asset menu, an aura sample, a registry entry, a place in the documented feature list and Teaching references. Applying it did nothing whatever you configured.
What changes for you: if your project applies Thorns — through the asset, an aura, the registry, or new ThornsStatus(...) — attackers now take a share of the damage they deal, as the percentage always said they would. If you had worked around the silence, remove the workaround. Reflection still needs the attacker to expose an IHealthMutator, and a hit already tagged Reflect is never reflected again. Without the Health module the status applies, ticks, expires and dispels as before, and reflects nothing — there is no damage pipeline to reflect through.
-
Status Effects & Pickups — a generated status pickup no longer goes inert after the first domain reload. The effect asset the pickup tooling writes held its status definition in a field Unity does not serialize, so the asset worked in the session that created it and read empty ever afterwards. The pickup then applied nothing, and reported nothing, permanently. Regenerate any status pickup prefab created before this release, or reassign its status on the generated
_Effectasset. -
Inventory, Pickups & Health — an item gated on health percentage now honours the gate from the bag. A definition authored "only usable below X% health" applied the gate when the pickup was collected from the world and ignored it entirely when the same definition was used as an inventory item — one asset, two behaviours, nothing warning about it.
What changes for you: such an item now refuses above the threshold and is not consumed. If a potion was drinkable at any health and you relied on that, clear the condition on the definition.
- Health — every
IHealthDeathHandleron an object now runs, not just the first. The cache was a singleGetComponentwhile the before-death cache beside it was aGetComponents, so a second death handler was silently inert — and the Lifecycle documentation lists four separate uses (animations, ragdoll, input disable, drops and cleanup) that invite putting several on one object. Handlers run in component order, and one that throws is logged and stepped over rather than taking the rest of the list and theDiedevent with it.
What changes for you: a second or third death handler that has been doing nothing starts doing what it was written to do.
-
Status Effects —
ShieldStatusno longer claims to absorb damage. It contributes a ticketed amount to Health'sShieldPoolfor its duration, and the pool is a value accumulator for UI, FX and your own code — Health documents it as not a shield, and nothing in the damage pipeline reads it. The status's own XML said it "absorbs", and its definition asset documented a fallback path that has never existed. Behaviour is unchanged; the documentation now matches it, and points at the Health shields that do absorb. -
Crafting — the authority documentation no longer describes discovery that does not happen. The Authority page published a four-stage resolution order for a
CraftingAuthorityResolverthat had no callers anywhere in the framework, which read as a description of what the service does.CraftingServiceuses the authority you assign it and scans nothing. The unused resolver has been removed rather than wired: Health and Status Effects resolve automatically because both have an explicitrequireAuthorityopt-in, and Crafting has none — so scene-wide discovery would silently start gating any project that happened to have a binder in the level. -
Inventory —
InventorySizeSynccan now be attached, as its documentation always said. The component wasinternal, referenced by no code and present in no scene or prefab, while its own README instructed buyers to attach it to scene objects. It is public and on the Add Component menu. -
Docs — the Health authority page no longer promises networking sample binders. It said sample binders for NGO, Mirror and Fusion were provided in the Samples folder. They were not. The page now documents the seam that does exist, and the binder's own tooltip no longer advises a replacement route — "disable this and provide your own
IHealthAuthority" — that never worked: automatic resolution searches for the binder component, not for the interface. -
CharacterInventory.ownerGuidno longer advertises itself as per-instance. The inspector tooltip called it an "optional per-instance id". It is not: the id is generated once and serialized, so every placement and everyInstantiateof a prefab shares the one baked into the prefab. The XML documentation has said so for a while, but the tooltip is the only version a designer ever sees, and it was the one making the promise.
The behaviour is unchanged and that is deliberate — nothing in the framework reads this field, so making it per-instance would change a serialized default on a shipped component in exchange for a property no framework code depends on. Clear the field on the prefab if you want each instance to generate its own on Awake.
- The welcome window is reachable on purpose, and now the docs say how. It opens on your first import and not again — a once-ever introduction rather than a per-project one, which is deliberate: it welcomes you to the framework, not to a particular project, and a modal in every new prototype would be a nuisance rather than a service.
What was missing is that nothing told you how to get it back. The README pointed at the "Test Suite (free download)" button inside that window without saying how to open the window, and neither the getting-started page nor the documentation index mentioned it at all — so a buyer starting a second project was sent to a button they had no way to reach. All three now name Tools ▸ RevGaming ▸ RevFramework ▸ Help ▸ Show … Welcome, and say that entries for packages you do not own are greyed out.
-
Inventory — passing
defaultas the container put items somewhere you could not see.ContainerId's constructor maps null to"backpack", butdefault(ContainerId)runs no constructor and left the value null — and the service canonicalises a container by taking that value straight, so(owner, null)keyed a real container that simply was not the backpack. The call reported success and a UI bound to the backpack showed nothing.default(ContainerId)now means the backpack, exactly as the constructor's documentation always said null does. -
Loot — a destroyed adapter was still treated as live. The service caches its inventory, currency and spawner adapters as interface references, and Unity's destroyed-object reporting lives on the
==operator ofUnityEngine.Object— which an interface reference never reaches. ALootServiceoutliving its adapters (aDontDestroyOnLoadbootstrap with the adapters in a gameplay scene) kept calling into them after the scene unloaded, and the currency adapter threwMissingReferenceExceptionstraight out throughRollAndGrant. Adapters are now read through a liveness check, so a destroyed one is absent and its awards are reported undelivered like any other missing adapter. -
Loot — the pickup carrier's "come back for it later" promise now enforces what it depends on. A carrier is consumed as soon as
Grantreports success, andGrantsucceeds when at least one award landed — so a carrier holding two awards that partly delivered would be destroyed along with the award that did not. Not reachable today (the spawner packs one award per carrier and the binding is internal), but nothing said so, andLootResultis plural-shaped. Development builds now warn if a carrier is ever bound with anything other than exactly one award. -
Teaching — the loot delivery panel stopped polling the scene. It re-resolved the service from
Tick, which runs fromOnGUIseveral times per frame per event, so a scene without aLootServiceperformed repeated scene-wide type searches indefinitely. The dependency guard in the draw path already did the same resolve when it was actually needed. Worth fixing in teaching material specifically, since it is the pattern these panels exist to argue against. -
Teaching — the Save area has a README, and it covers the two things the panel cannot show you.
RevSaveReport.UnappliedversusPartiallyAppliedis the decision the report structure exists to enable — a section that failed before applying anything is safe to carry into the next save, and one that failed halfway is not, because carrying it would overwrite the owners that did load. The panel demonstrates failure isolation and carry-over but not that distinction, and not migrating an older section forward. Both are now written down beside it. -
RequireEscrownever meant "every spend goes through a hold", and now says so. The policy tooltip read "debits and transfers must use escrow". The guard is a capability check: it failsDebit/Transferwhen no escrow layer is present, and forwards them unchanged once one is. Two paths it does not touch at all:Credit, and — the one that matters —SetBalance, which is a spend when it writes a lower number, and which admin panels, cheat menus and restore paths all use. A team auditing "can anything spend without a hold?" would not think to look at a setter. The tooltip, the API docs and a new test now all say which paths are gated and which are not. -
Saving while an escrow hold is open loses the held money, and nothing said so.
TryHolddebits immediately and returns a runtime token; a save captures balances only. So a snapshot taken mid-hold records the already-reduced balance, and the token that would have returned it is not in the file. Load it and the money is gone — not refunded, not still held. This is inherent: no persistence surface can carry a hold, because a hold is keyed by a runtime token and an ownerGameObject. Documented in the escrow guarantees and on the save component, with the remedy (commit or release before saving) and a test holding both halves in place. -
The idempotency replay window now tells you when it fills. Recent request ids live in a fixed-capacity ring, so protection covers the last N operations for an owner rather than all of them — past N, a retry applies again. That bound is correct for the offline, single-process use it is for, but it was invisible. Editor and development builds now warn once per service the first time a window fills, naming the capacity and what to size it against. Growing the ring instead was rejected: it would trade a bounded, documented window for unbounded memory in exactly the busy session that reaches the limit.
-
Documented which comes first: your code handler or your inspector hook. Every system raises events twice — a C#
eventfor code and a serializedUnityEventfor the inspector — and the order is not the same everywhere. Currency and Crafting raise the C# event first; Health and Status Effects raise the designer hook first. Nothing said so and nothing pinned it, so a buyer could learn the order from one system, rely on it in another, and get the opposite.
There is now a table in the documentation index stating each system's order, the explicit statement that this is a per-system fact rather than a framework guarantee, and advice for when you genuinely need two handlers ordered relative to each other. Each order is pinned by a test, so it cannot flip in a patch.
Deliberately not unified. It is drift rather than a decision, but making it uniform would silently reverse the order for anyone already relying on it, in the two systems that raise the most events. Documented and pinned beats a quiet behaviour change.
-
AuthorityGuardno longer claims to be something it is not. Its summary called it the "standardized helper for authority-gated mutations" while having a single consumer, which reads as six systems ignoring a convention. They are not ignoring it — they are not doing that job. Health is the only system that refuses an individual mutation and warns about the refusal; the others gate earlier, when they resolve an authority, and their warn-once flags report a different condition entirely. The summary now describes the narrow thing it actually does. -
Buying two SKUs made the framework greet you as a Complete-bundle owner. Without the
COMPLETE.markerfile, the welcome coordinator fell back to a heuristic: owning two or more system groups counted as Complete. That is exactly what a two-SKU buyer looks like — so they were shown a Complete window naming systems they had never bought, and both of the windows they should have seen were force-marked as already shown. Those marks are machine-wide, so neither would auto-open in any project on that machine again. (Both remained reachable fromTools ▸ RevGaming ▸ RevFramework ▸ Help, which is what kept this a first-run annoyance rather than a lockout.)
The marker is now the only Complete signal, which is sound because PackageInfo/ ships in the Complete export and in no system SKU — now pinned by a test, since removing the fallback moved the weight onto it. The force-marking is gone too: that branch is reached every session for a real Complete install, so it bought nothing and only made a wrong answer permanent instead of self-correcting.
-
A dev tool that looked like a gate and was not.
PublicApiLeakCheckerchecked that no public type sits in an.Internalnamespace — from a menu, with its failure path commented out, called by nothing, and shipping in no package. Replaced with a test that runs on every push and covers every loaded framework assembly, rather than the eleven the release-time snapshot tracks. -
The release checklist now says which exporter to use. Every packaging gate runs inside the framework's own export menu item; Unity's built-in
Assets ▸ Export Package…produces a package that looks identical and has had no gate run against it at all. There is no way to intercept the built-in exporter, so the menu route is the guarantee — and it is now a checklist item instead of an assumption. -
CI could lose a whole test suite without failing. The build carried a minimum test count sized to catch the largest single assembly vanishing — which left roughly 117 tests of slack in EditMode, more than the entire Save suite, all six Integrations suites combined, or Loot. Any of those could stop compiling and the build stayed green, while
TESTS.mdtold you this exact failure mode turns it red.
Every test assembly that exists on disk must now appear in the results, named individually, so a suite that stops compiling fails the build instead of shrinking it quietly. The expected set is read from the assembly definitions rather than a list someone has to remember to update — this project already keeps several hand-written registries of "what exists" and every one has been missed at least once. TESTS.md now describes both checks accurately.
-
Two documented API clauses had no test.
ICurrencyExchangedocumentsNotFoundwhen no rule exists for a pair — the code a caller branches on to tell "not tradeable" from "refused" — and the exchange suites covered everything except that path.TryTakeTrueDamagedocuments that it does not addDamageTag.True, which is exactly the kind of clause that gets helpfully "fixed" into silently firing every rule a buyer wrote against true damage. Both are pinned now. -
A transaction test asserted something that had never happened.
CurrencyTxn's owner-destroyed case ended in an unconditional pass after checking only that the transaction failed. Adding the two assertions that would have caught this turned it red immediately:Commitruns a full simulated precheck, so an unaffordable operation is refused before anything is applied — no balance moves, no wallet event fires, and the hook meant to destroy the owner mid-transaction never runs. The apply phase is also wrapped in a batch, so per-op events are held until every op is done; a mid-apply hook is not constructible at all. Replaced with a test of the guarantee that was actually holding and had no name: an unaffordable transaction costs nothing, because it never starts. -
A test fixture could be silently disabled by a rename. The status-effect listener-isolation suite bootstrapped its controller through
GetMethod(...)?.Invoke(...). Had that method been renamed, every controller in the file would have been inert — and an inert controller satisfies the end-state assertions, because nothing was ever applied. It now asserts the method exists, and the fixture's control checks its precondition rather than only its outcome. -
The stated rule for the Hostile test suites was not the rule they follow. The published table said Hostile tests use no reflection and no private access. Twenty-five of a hundred and eighty do use reflection, and must: a
[SerializeField]field is authored in the inspector and has no code-facing setter, and EditMode does not runAwakeorOnEnable. The documented line is now the real one — what a test asserts on — with the one fixture that reads private runtime state called out as the exception it is. -
Two systems ship damage-over-time, and nothing said which to use. Health has
DotEffect/HotEffect; Status Effects hasPoisonStatus,BurnStatusand — through the Status ↔ Health integration —RegenStatus. Both tick damage through the Health pipeline, and everything else about them differs: stacking model, dispel and cleanse support, potency and resistance scaling, whether the tick is attributed to an attacker, and whether it shows up in a buff bar. No page compared them, and Health's own integration guide simply said "Poison DOT →DotEffect".
There is now a comparison in Health → Integration Surfaces, linked from the Status Effects one: a line-by-line table, guidance on which to reach for, and a warning that switching later is a balance change rather than a refactor — the two do not compute the same totals, and their damage typing differs, so resistances that matched one may not match the other.
-
Crafting quickstart panel claimed to have queued more than it did. In normal (non-batch) mode
Enqueueattempts up to the requested count, stops at the first craft that cannot start, and returns the last successful job. The panel treated any non-null job as full success and reported the number requested — teaching the exact opposite of the contract, since only batch mode is all-or-nothing. It now reports what was actually queued, and says why it stopped short. -
Two published code examples that could not compile. The Status Effects teaching README showed
controller.Apply("poison", …)assigned to aresultwhose.ToUserMessage(…)it then called — three errors in five lines: no such overload,ApplyStatusreturnsvoid, and no such extension exists for that type. It sat directly under the page's claim that the panels demonstrate the call patterns your own code will use. Rewritten to what compiles, and pinned as a test so the next revision has to as well.
The Currency teaching README's correction was itself wrong: it said ToUserMessage for a CurOpResult lives in the Inventory ↔ Currency integration and that adding the integration gives you its phrasing. That helper is internal, so it is not callable from your project either way. The page now says so and points at ReasonText, which is public.
- Health —
int.MaxValuedamage did nothing. The "dealint.MaxValuedamage as a guaranteed kill" idiom is common in death volumes and out-of-bounds triggers. It rounded through afloattoo large forint, landed onint.MinValue, and the pipeline'sMax(0, …)turned that into a zero-damage rejection — so the target survived at full health. Whether it did survive depended on the platform's float-to-int conversion, so the same logic could kill on one device and not another.
Measured rather than assumed: on Unity 6000.3.5f2, Mathf.RoundToInt(2147483648f) returns int.MinValue, and that measurement now ships as a test so a Unity version that changes it says so. The affected band is roughly the top 128 values; int.MaxValue - 127 was always fine.
The same expression appeared in six places, not the one this was noticed in — damage, healing, and four preview paths — and the heal path had two more overflows the scale step does not cover: the healing-modifier multiply, and adding the heal to current health, which wrapped negative so a large heal was written and then reported as failed. All are bounded now, and the guard is deliberately narrow: every amount that worked before produces the identical result, including the banker's rounding the damage pipeline documents as part of its contract.
-
Status Effects — applying a status before the controller's
OnEnablethrew. The math service was resolved only inOnEnableand used unguarded everywhere else. Unity runsOnEnablein component order, so a sibling earlier in that order — a class-kit component seeding passive buffs is the obvious case — got aNullReferenceExceptionon the first scene load and the buff was silently not applied. Services now resolve on demand, and re-resolve if the configured service is destroyed. -
Status Effects — overlapping aura zones fought over one shared provider. Two zones covering the same actor wrote to a single component, so the actor got whichever zone configured it last, and whichever zone it left first destroyed the component the other still needed — which that zone's next recompute silently re-added, so the scaling oscillated while the actor stood still.
Each zone now contributes separately. Potency multiplies and resistance takes the minimum, both order-independent, so a 2× poison aura crossing a 0.5× magic aura gives 1× no matter which was entered first, and leaving one leaves the other intact. A single zone behaves exactly as before.
-
Crafting — space preflight disagreed with delivery about item guid casing. A recipe producing the same item through more than one channel — a base output plus a chance output, say — had each spelling budgeted separately, because the cumulative space check compared guids case-sensitively while everything downstream does not.
ItemDatabasekeys its lookup ignoring case and collapses duplicates, so"Plank"and"plank"cannot be two different items; the shipped inventory adapter counted them as one. The result was a preflight that accepted a craft delivery then refused. Atomic delivery always backstopped it as a cleanNoSpaceAtDeliverywith a refund, so this cost a wrong answer rather than lost items. -
Crafting — designer UnityEvents and C# events came out in a different order depending on how you enqueued. Enqueuing separate crafts raised all three C# events and then all three designer mirrors; enqueuing a single batch interleaved them phase by phase, as does every other emission site in the service. A designer hook priming state that an
OnJobAcceptedhandler reads therefore worked in one mode and read stale state in the other, with nothing in the API to say which you were getting. The per-craft path now matches, and the guarantee is written down: C# first, then its mirror, identically in both modes. -
Crafting — the completion-dedup record grows for the life of the service, and now says so. Every terminal delivery records a completion id so a replayed snapshot cannot deliver twice, and nothing removes it, so a long session with heavy crafting accumulates them without bound — an idle game being exactly the offline-progress audience.
ClearAppliedCompletions()was documented as an optional post-restore tidy-up when it is in fact the only thing that prunes; its documentation now says that, and says when calling it is safe. Editor and development builds warn once when the record grows large.
A size cap is deliberately not offered. Evicting the oldest ids would silently re-open double delivery for precisely the completions a restore is most likely to carry, and the service cannot tell an evicted id from one it has never seen. Reporting the growth leaves the decision with the host, which is the only place that knows when its ids are dead.
- Published documentation that described something other than what ships. Seven pages, no code path changes. Each was a claim a reader could act on and be wrong.
The Pickups → Inventory integration README documented a design that was deleted. It described resolving Inventory "via reflection", listed the method signature a service had to expose, and told you Pickups does not reference the Inventory assembly — all of which stopped being true when that reflection was removed for never having worked. The page now describes the direct, compile-checked call, and states where the decoupling actually lives: this integration's own assembly is gated on REV_PICKUPS_PRESENT and REV_INVENTORY_PRESENT, so it compiles only when both systems are installed. It also documents that a refused grant is reported to the pickup, which is why a full bag now leaves the pickup in the world.
The Currency guarantees matrix overstated the RequireEscrow guard. It read "Debit/Transfer must use escrow". The guard checks that an escrow layer is present in the stack; once one is, a direct un-held Debit still succeeds. A team enabling it as an exploit-resistance measure was buying a guarantee that does not exist. The matrix now says what the guard does, and that forcing spends through hold → commit is a rule for your own call sites. The Currency FAQ and PublicAPI pages already described it correctly.
The same matrix said OnWalletChanged fires "after successful mutation". It fires when the stored balance actually changes, so a Credit of 0 or a SetBalance to the current value returns Ok and emits nothing — which matters if you use the event as a completion signal for a grant that computes to zero. Both behaviours were already pinned by tests; only the page was wrong.
TESTING.md argued from a number that no longer checked. It cited ~45,000 lines of tests against a tree that measures ~86,000, on the page whose whole case is that a figure you can verify beats an adjective. Re-measured, and now guarded by a test so it cannot drift three releases again.
The Loot testing-philosophy page understated its own suite. It listed two test files and called the pickup path Loot's largest coverage gap; there are four files across two assemblies, and the spawner and payload carrier have been covered in a real scene for some time. It also claimed a convergence sample five times larger than the one that runs. Corrected against the tree, with the remaining genuine gap — LootDropOnDeath — named plainly.
The Teaching index promised an Economy teaching area that does not exist. "Offers, pricing, adapters, transactions, diagnostics" is one quickstart panel doing Buy and Reward against a currency-only economy. The Currency teaching page also drew a folder tree matching nothing on disk, and listed CurrencyInventoryBackedPanel as if it shipped with Currency — it ships with the Inventory ↔ Currency integration, which is why owning Currency alone would not find it.
TESTS.md had no row for Samples/. The coverage table claims to state "the actual shape" of the suite and omitted the folder entirely. Sample coverage is crash-only — 42 scenes load clean, and none of the 56 sample scripts has a behavioural assertion. Stated as its own tier, including why that is bounded, and the two figures are now guarded against drift.
- Debugger windows — two panels read live state through reflection and reported a default when it failed. Both the Crafting and Status Effects debugger windows reach parts of the systems they inspect by member name, inside
catchblocks that swallowed everything. A rename compiled clean and the window silently showed a fallback — and because the fallback is a plausible state ("no cooldown configured", "authority not required"), there was nothing to notice.
Two of those reads did not need reflection at all. The Crafting window read CraftingService's private serialized outputRouterComponent — the inspector slot, not the live binding — so a router installed at runtime through SetOutputRouter was invisible and the window reported routing that was not the routing in effect. It now uses CraftingService.OutputRouter, public as of the output-router fix above. The Status Effects window reflected the controller's timeMode backing field when StatusEffectController.TimeMode is public.
The reads that genuinely have no accessor stay reflective, but every miss now reports itself once — naming the member and saying the panel is showing a fallback.
- Inventory —
Move's contract now states the two things it actually does. The XML said "a successful result when the move is applied", which oversold it in two ways.
A partial move reports plain success. When the target slot can absorb only part of the source stack, that part merges, the rest stays put, and the result is Ok — indistinguishable from a whole-stack move, with no channel for the moved quantity.
A refused target slot falls back to auto-placement. If the requested slot cannot take the stack, Move does not fail; it places the items anywhere in that container that will accept them, and fails only when nothing will. A drag-and-drop UI cannot assume the item landed where the player dropped it.
Both are deliberate and pinned by tests, so they are documented rather than changed — projects rely on a move that works rather than refuses. The contract also now states, in one place, that the three bulk operations report partial success three different ways: AddMax succeeds with a remainder out-parameter, TransferResult fails with InvOpCode.Partial, and Move succeeds silently.
-
Teaching — a Pickups panel shipped inspector knobs wired to nothing at all. The interactable pathway in
PickupsWorldAndInteractablesPanelwas a closed dead loop: three prefab catalogs fed three name caches, which fed three clamped selection indices, which assigned three prefab fields, which were read by one privateSpawnInteractablemethod that nothing ever called. Ten members and a settings block — hold seconds, facing threshold, respawn, respawn delay — none of it reachable, and no panel UI rendered any of it. Removed. The world-pickup pathway beside it is untouched and works. -
Teaching — a Loot panel carried a local copy of a helper it already inherits, justified by a note that was wrong.
LootDeliveryPanel'sGUIEnabledScopeShimsaid the scope helper "lives in an Editor assembly that Teaching cannot reference". It does not:PanelBase— which this panel inherits — declaresGUIEnabledScopein the same runtime assembly, and every other panel uses it unqualified. The shim is gone and the inherited helper used. -
Packaging docs — three instructions that could not be followed, or pointed at nothing.
The REV_TEACHABLES instruction was impossible. The README told you to disable the define before building. You cannot: it is derived from whether Teaching/ is present and re-applied within a fraction of a second, so clearing it in Player Settings never sticks. The build guard's own error message repeated the same impossible advice. Both now name what actually works — Pre-Build Clean, or removing the folder — and say plainly that the define follows the folder.
Sample paths pointed at a layout that does not exist. getting_started.md and Documentation/Inventory/README.md used Samples/<System>/…; the real layout is Samples/Systems/<System>/Scenes/. Samples/README.md listed a folder tree that matches nothing on disk — it was describing the documentation site's page tree, which is now said explicitly, with the disk path beside it. The entry scene is the one numbered 00_, and its name varies by system, which the docs previously implied it did not.
The sample scenes expect two packages, and the docs only warned about pink materials. 34 of the 42 scenes carry component references to the Input System and 9 to Universal RP. Without those packages the affected objects load with missing scripts — the EventSystem has no input module, so uGUI demo interaction simply does not respond. That is a different failure from a mis-shaded mesh, and it is now documented as one. Neither package is a hard dependency of the framework itself.
Documenting it is not enough on its own, since a sample scene is the first thing the README tells a new user to open. Opening one now also detects the problem and names the cause — see the Samples entry under Added.
-
Inventory — an equipped visual stayed welded to the character when the slot id's case did not match.
CharacterEquipmentnormalises the slot id before touching the container, but raisesOnEquipped/OnUnequippedwith the caller's raw string.EquipmentVisualAttacherused that raw string as its dictionary key, soEquip("WEAPON")followed byUnequip("weapon")did the right thing at the container — both resolve to the same slot — while the despawn lookup missed. The item came off and its visual stayed attached. Surrounding whitespace did the same. The attacher now keys by the normalised slot id, the same key the container indexes by. -
Inventory — the Public API page promised authority checks that
CharacterInventorydoes not make. "Authority policies are respected for mutations" was listed as an unqualified runtime guarantee. It holds for mutations made through the service;CharacterInventory's ownTryAdd,TryRemoveResultand friends forward straight to the bound container and do not consultIInventoryAuthority. That is deliberate and pinned by tests — the component is a direct handle on a container — but the page oversold it, and the component is public, so aUnityEventwired in the inspector can move items an authority policy would have refused. The guarantee is now scoped, with a warning saying where to route mutations if your authority rules must hold.
CharacterInventory.ownerGuid is documented honestly too: it is not unique across copies of a prefab, because the initializer runs once and the result is serialized, so every placement and every Instantiate shares one baked id. Nothing in the framework reads it — the save integration keys by Core.Identity's StableId. Left as it is deliberately: dropping the initializer would break the pinned guarantee that a freshly added component already has an id. Clear the field to get a per-instance one.
- Documentation — three places where the docs asserted something that was not true.
The Crafting pages referred to CraftingServiceCore, a type that does not exist and never has. Ten references across five files, including the published Crafting guarantees page and an Integrations README — all of them meant CraftingService.
The Currency teaching page advertises "direct, copyable" snippets and two of them did not compile. OnWalletChanged was shown taking four arguments when it carries a single CurrencyDelta, and the failure path called ToUserMessage, which lives in the Inventory ↔ Currency integration rather than in Currency — so a project owning Currency alone could not build the thing it had just been told to copy. Both are corrected, and the example now exists as a test, so the next signature change breaks the build rather than the page.
HealthDebugPanel listed "safe to ship in runtime builds" among its design goals. True of the code — every editor call is fenced — and false of the package: it compiles under a REV_TEACHABLES-gated assembly, and the build guard raises a compile error for any player build with that define. It is an editor-time teaching aid; copy the patterns out of it rather than shipping it.
- Crafting — every teaching panel stopped reporting rejected crafts if its service arrived late. All six subscribed to the service's events in
OnEnableand nowhere else. ACraftingServiceresolved after that — by the panel's own dependency guard, or simply spawned later in the scene — was never subscribed at all, so the panel looked alive and silently produced no rejection feedback. This is the same late-binding family as the v1.0.3 panel bugs, in the one place it had not been closed: those were fixed for target binding, and event subscription was left behind.
Each panel now re-checks its subscription from Tick, detaching from the previous service before attaching to the new one. CraftingWorkbenchBasicsPanel already had that mechanism and simply never re-ran it.
- Crafting — swapping the output router temporarily was not expressible, so the demo panels destroyed a router the project had configured.
CraftingService.SetOutputRouterwas public and there was no way to read the current router, so "put back what I found" could not be written. The only available restore wasSetOutputRouter(null), which discards rather than restores. Both shipped Crafting demo panels did exactly that: enabling one replaced whatever router the project had bound, and disabling it again left the service with none.
CraftingService.OutputRouter is now readable — a read-only property, purely additive, nothing removed or renamed. Binding still goes through SetOutputRouter, where the validation lives. This matters beyond the demo panels: any debug overlay, cutscene or test harness that swaps the router for a while had the same problem and the same inability to fix it.
Both panels now capture the router before replacing it and restore it on teardown, whether or not they created the teaching router themselves. Only the destroy stays conditional — a router the scene already owned is not theirs to tear down.
- Inventory — a snapshot name went into the file path untouched, and a failed write reported success.
InventorySnapshots.GetPathcalled its localsafeand did nothing to earn the name: the caller's name went straight intoPath.Combine, which discards its first argument entirely when the second is rooted. SoC:\...or/etc/...wrote wherever it said,saves/slot1wrote into a folder that usually did not exist, and a name containing a character the filesystem rejects threw out of whatever called it. Names are now reduced to a single safe file name — separators and rejected characters become_, and nothing resolves outside the snapshot folder. A name with nothing wrong with it is left exactly as it was, so snapshots saved by an earlier build are still found.
SaveJsonToFile is documented to return false when the file was not written and could not honour that, because File.WriteAllText was unguarded: a full disk, a read-only file or one held open by another process threw past the caller instead of giving it the false its contract promised. Callers written to the documented contract were exactly the ones that broke. It now catches, logs the reason, and returns false.
The dead SaveToFile overload — internal, no callers, and carrying the same unguarded write — is deleted, matching how the framework has handled its other dead internals.
- Teaching — three panels reported success the service never gave them. All three are in the hostile consumer panels, whose whole subject is what happens when things go wrong.
InventorySnapshotsPanel called SaveJsonToFile in statement position and announced "Saved snapshot" whatever came back — in the panel whose stated doctrine is that it does not fake success, and whose sections are advertised as copy-pasteable. CraftingOfflineProgressPanel cancelled every active job, discarded every CancelJob result, and showed a green "Cancelled active jobs" banner even when the service had refused all of them — or when there was no service wired at all. Both now report what actually happened, including partial success.
CurrencyPolicyCapsPanel was the worst of the three, because it pre-empted the service in the one panel whose subject is policy. When its own preview predicted a clamp it wrote the predicted balance with SetBalance — computed from a balance read earlier in the same frame, which is a lost update presented as a pattern to copy, and which recorded a Set in the audit trail where the player had earned or spent. A debit larger than the balance was failed locally without the service being called at all, and the predicted failure also disabled the Apply button, so a composed stack whose clamp or overdraft rules differ from the base service could never be reached. The preview stays — predicting the clamp is the teaching point — but it no longer replaces the operation or gates it, so when prediction and policy disagree the panel now shows the disagreement.
- Pickups — collecting a pickup in a scene with no authority scanned every active MonoBehaviour, every time.
PickupAuthoritycached a fruitless search as a null, commented "cache miss to avoid repeated scans". It avoided none: the lookup requires the cached entry to still be usable and a null never is, so every call fell straight through to the full pipeline — a parent walk, aGetComponentInChildrenper scene root, and aFindObjectsByTypeover every activeMonoBehaviour. An authority is optional and most scenes have none, andTriggerPickup,UnifiedPickup2DandInventoryPickupInteractableeach resolve on every accepted enter, so that ran once per collection — a hitch source on large scenes that the comment claimed could not happen. (This entry namedInteractablePickupBasewhen it shipped. That base resolves nothing and never has; the subclass that does isInventoryPickupInteractable. Corrected in 1.3.0 — the fix itself was as described.)
A scene that comes up empty is now remembered for the rest of that frame, the same shape and for the same reason as CurrencyAuthority. Deliberately keyed by frame rather than cached outright: an authority enabled later has to be found, and a sticky miss would leave every pickup in the scene refused for good — far worse than the scan it saves. The trade is that an authority spawned mid-frame is picked up on the next frame; PickupAuthority.Invalidate() applies it at once. The memo does not apply outside play mode, where Time.frameCount does not advance and a miss would never expire.
Found while fixing it: Scene does not implement IEquatable<Scene>, so a Dictionary keyed on it falls back to the boxing comparer — roughly 20 bytes per lookup, on a path every pickup runs on every collection, whether or not an authority was ever found. Both dictionaries here now compare through the == operator, which is what makes the memoised call allocate nothing at all rather than merely less. The same mistake turned out to be framework-wide; see the entry below.
- Currency, Health, Status Effects — the scene-scoped resolvers allocated on every lookup, including the ones that hit their cache.
Scenedoes not implementIEquatable<Scene>, so aDictionaryorHashSetkeyed on it falls back to the boxing comparer: every lookup callsEquals(object)and boxes the key, measured at roughly 20 bytes a call. Eight collections across five files were built that way. Nothing was wrong and nothing was visible — the answers were always correct, and only the litter was new.
The widest of them is CurrencyResolve.ServiceFrom, which is how most code reaches the currency service; the most frequent is CurrencyAuthority, resolved through a property that re-runs on every guarded Credit and Debit. CurrencySetLocator is read by save and by the currency panels. All four now compare through the == operator instead, which keeps the key on the stack.
HealthAuthority and StatusAuthority are corrected too, for consistency rather than for a saving worth quoting — both are memoised by their callers after the first resolution, so the cost there was a few bytes per component rather than per operation. Said plainly so the entry is not read as a claim about the damage path.
-
Loot — with pickup spawning on, an award the spawner refused was written off without ever asking the inventory. The fallback ran one way only. A refused direct delivery tried the spawner — that direction is documented and worked — but a refused spawn went straight to
Undeliveredwith a working adapter bound and room to spare. The container was not merely skipped; it was never resolved, because the lookup was gated on not being in spawn mode. The guarantee matrix describes "both refused" as the condition for an undelivered award, which was only true coming from one direction. Both the item and currency paths now mirror it properly. Observable: awards that were being reported as lost may now arrive in the bag or the wallet — that is the fix, not new behaviour. -
Loot — an entry that can never award anything now says so. A picked entry with no item GUID, no currency id, or no nested table assigned consumes its pick and awards nothing. In Weighted mode the pick is spent before the entry is inspected, so the entry acts as a hidden Nothing entry — it takes its share of the rolls and produces no drop, which from the outside is indistinguishable from bad luck. Sampling a table cannot reveal an outcome that never appears, so the mistake now reports itself in the editor and in development builds. A real
Nothingentry is a deliberate outcome and stays silent. -
Crafting — the 2D workbench charged the wrong object with a craft, and let anything in its trigger spend the cooldown. Both were fixed on the 3D bench and never carried across. A 2D character's trigger collider commonly sits on a child of its
Rigidbody2D, and the 2D bench took that child as the craft owner — at which point an empty backpack is created for it, because containers materialise when asked for, and the craft fails for missing materials the player is visibly carrying. The owner is now resolved through the attached rigidbody, and the bench honours the samepreferTaggedPlayerOwneroption as its twin. Separately, the repeat cooldown was taken before the owner was checked at all, so a crate sharing the trigger consumed it every cycle and starved the player's attempts; it is now taken only once the collider is confirmed. -
Crafting — two documentation claims that did not describe the code. The README said validators apply during offline completion; they do not, and never have. Validators run when a craft is accepted — by the time an offline job is restored its inputs are already spent, so a refusal there would leave a job that can neither proceed nor be undone. If you need a rule at delivery time, routing and the delivery adapter both run there. Separately,
ExpectedCeilwas documented asceil(sum(chance × quantity)); it budgetsceil(chance × quantity)per output, and how those budgets combine is decided by the space check — cumulative per container and item, but checked independently across different items. Both are now described as they behave. No behaviour changed. -
Inventory — a hand-edited save could grow a container to any size at all. Restoring a snapshot grows the container when the saved layout has more slots than it currently holds, so an upgraded bag is not truncated on load. That slot count came straight from the file with nothing above it, so a save asking for tens of thousands of slots got them, and a large enough number is an allocation stall from one file. Per-slot quantities were already clamped to each item's max stack — the slot count was the one restored quantity with no ceiling. There is now a bound, settable through
InventorySnapshotOptions.maxRestoredSlotsand defaulting to 4096, and it says so when it bites rather than quietly dropping the rest of the layout. Saves within the bound restore exactly as before. -
Inventory — a restored item could carry a durability that made it stack with anything. Durability is deliberately an arbitrary value whose meaning is yours, so negatives are left alone — but
NaNsurvives a JSON round-trip, and every comparison againstNaNis false. A stack carrying one therefore reported equal durability to every other stack, defeating the rule that a durability-37 sword merges only with another durability-37 sword. Non-finite values are now replaced with zero on restore. Nothing a running game produces is affected. -
Save — a section whose entries had all lost their ids restored as a clean success. Entries with a blank owner id are skipped, and they were skipped before being counted — so a truncated or edited section in which every entry had lost its id restored nothing, reported no problem, and left the system holding whatever was live before the load. The existing guard for "this save names owners and none of them are here" could not see it, because by its count the save named nobody. All four participants — Inventory, Currency, Health and Status Effects — now report it. A genuinely empty section stays silent, as before.
-
Currency — the JSON save utility threw out of its own Load button on a damaged file.
CurrencyJsonSave.LoadNowread and parsed with nothing guarding either step, so a save truncated by a crash or edited by a player threw out of whatever called it: no balances loaded, noOn Loadedevent, and nothing said. It now reports the failure and changes nothing.SaveNowis guarded the same way, so a full disk or a read-only folder is reported rather than thrown — the existing save is left intact either way, which was already true and is now the thing you get told. -
Build tooling — Pre-Build Clean and Cleanup Orphaned Content could move content out of a shared source tree, not just out of your project. If
Assets/RevFrameworkis a junction or symlink pointing at a working copy elsewhere — one package shared across several projects, or a package kept under its own version control —Directory.Movefollows the link to the real content. The clean emptied the linked location for every project and checkout reading from it, while the dialog promised only to tidy this project up and offered to move it all back.
Both tools now recognise the case. Cleanup Orphaned Content refuses and names the link the folder is reached through; Pre-Build Clean asks first, in its own dialog, with the option that changes nothing as the default. The check walks the ancestors, and it has to — with the link at the framework root, the folders these tools actually move are ordinary directories inside the link's target and carry no link attribute of their own, so inspecting only the folder being moved reports "not linked" in precisely the case that does the damage. Projects that do not use links are unaffected.
- Build tooling — Pre-Build Clean stopped partway when a folder was open, leaving the project half-cleaned.
Directory.Movefails on Windows when anything in the tree is held, and nothing released Unity's file handles first —AssetDatabase.Refreshran only after every move had been attempted. The ordinary way to hold something inSamples/is to have a sample scene open, which is what somebody does immediately before building, because the README tells them to open one. The clean threwIOException: Access to the path … is deniedout ofOnPreprocessBuild.
The crash was the smaller half. The loop let that first failure escape, so folders earlier in the list were already in the trash while later ones had not moved, and the exception said nothing about the project having been left in that state — which is the state the rest of this tool exists to prevent. It stops a build rather than let it continue past an incomplete clean, and here it was producing the incomplete clean itself.
Handles are now released before the first move. Each folder moves independently, so one that cannot be taken is recorded while the rest still move — the outcome is a complete clean or a complete report, never a silent half. Failures end the build through BuildFailedException, as this tool's other deliberate refusals do, naming the folders, the likely cause and the way back: nothing is deleted, and Tools ▸ RevGaming ▸ RevFramework ▸ Tools ▸ Restore From Trash… returns whatever did move.
-
Currency — an escrow expiry pump with no service to find re-scanned the entire scene every frame.
CurrencyEscrowExpiryPumpretried resolution fromUpdatewith no interval, and resolution misses are deliberately never cached, so every attempt walked each root object in the scene and allocated as it went — indefinitely, and in silence, because a pump with nothing to sweep has nothing to report. Any pump placed before its service exists, or dropped into a scene without one, was doing this. The probe is now throttled to twice a second, matchingCurrencyBar. Sweeps still run on the interval you configure; the only change you can observe is that a pump may resolve up to half a second after its service appears. -
Pickups — the authority cache attached another set of scene handlers on every play session.
PickupAuthoritywires threeSceneManagerhandlers from a runtime initializer, which runs again each time you enter play mode. With Enter Play Mode Options set to skip the domain reload, the previous session's handlers survive that boundary, so they accumulated: after N sessions every scene load and unload ran 3N of them. No result was ever wrong — each handler is an idempotent cache eviction — and player builds were never affected, because they initialize once. Editor-only, and it reset whenever you restarted Unity. The wiring is now idempotent. -
Pickups — a pickup refused for facing the wrong way said nothing. Set a facing threshold and wire a "wrong way round" sound to the fail feedback, and pressing interact while misfacing produced silence: the facing check returned without running the fail feedbacks or raising either failure event. Every other failed condition reported. It now reports too, and — because a hold-to-pick-up pickup re-checks every frame — the hold timer resets with it, so the feedback fires once per attempt rather than once per frame. If you had a facing threshold and failure feedback configured, you will start receiving events you were not getting before.
-
Pickups — a composite pickup could do nothing at all for actors without health. An effect declares whether it can run against an actor with no
IDamageable, and a composite never declared it — so a composite was refused as a whole even when every effect inside it would have worked on its own. A currency-plus-VFX pickup, which the composite prefab tooling creates in one click, was silently inert for any actor without health, in a project that may not use health at all. A composite now allows it when every child does, so anything containing a damage or heal effect behaves exactly as before. Composites that were doing nothing may now fire — and if they are set to destroy on use, be consumed. -
Pickups — the same definition could ignore its own health condition depending on how it was built. "Require target health below threshold" is applied by the Health integration's build path. The pickup creator wizard used it; the Create Prefab From Definition… menu did not, so the same asset gated correctly from one and fired unconditionally from the other. Both now use the same path. The tooltip also says where the condition applies: an effect built through the core factory — which includes using a pickup definition as an inventory item's use-effect — still ignores it, and that remains deliberate so Pickups does not depend on Health.
-
Status Effects — an aura zone could leave its stat modifiers on an actor permanently.
StatusAuraZonetracks the collider it saw, but attaches its providers to the controller host, which it looks for on a parent first. Exit handled that correctly; disabling the zone did not — it went looking for the providers on the collider, found none, and destroyed nothing. Whenever those two were different objects, which is the ordinary setup of a trigger on a child hitbox, the actor kept the zone's potency multiplier for the rest of the session, scaling every status it received afterwards. Since the provider component is internal, there was no way to remove it from your own code either. Disabling a zone now resolves the same host that received the providers. An actor destroyed while still inside no longer aborts the cleanup for everyone else in the zone, either. -
Status Effects — re-applying the same effect instance could leave a stack that never went away. Applying one
IStatusEffectobject twice under theStackrule tracked it twice but recorded its context once. When the first copy expired it took that record with it, and the second was left behind: never ticked, never expired, and still counted byActive,GetStackCountandHasStatus. It could only be cleared by removing the status by id. Re-applying an instance that is already running now refreshes it. Effects are documented as requiring a fresh instance per application and that has not changed — but ignoring it should not corrupt the controller. -
Pickups — a pooled or deactivated actor could come back with a shield that never expired.
ShieldSystemtimed its shield with a coroutine, and Unity stops coroutines when a GameObject is deactivated. An enemy put away mid-shield returned with one that absorbed forever — and could not even be given a new one, because a shield already counts as active. Applying a shield to an inactive object had the same result for a different reason: there was nothing running to expire it. Expiry is now a deadline rather than a coroutine, so it holds across deactivation. Two consequences worth knowing: time spent deactivated now counts toward the duration, where the coroutine used to pause with the object; and after a deactivation the depletion event fires the next time the shield is queried or used, rather than at the exact moment it runs out. -
Health — damage and heal rules sharing a priority could run in a different order once there were more than sixteen of them. Both rule hubs sorted with
Array.Sort, an introsort: it falls back to a stable insertion sort at sixteen elements or fewer and reorders equal keys freely above that. So a GameObject carrying seventeen rules could run them in a different order than the same setup with fifteen, for no reason visible to anyone reading it — and order matters here, because a rule that cancels the hit stops the ones behind it. Both hubs now sort stably, so rules that declare the same priority run in the order their components sit on the GameObject, which is what the inspector shows. That is the same guarantee Loot already makes for its modifiers, and it is now documented on both hubs and covered by tests. Nothing changes for the usual case of a handful of rules. -
Currency — an escrow hold could be refunded twice, creating money. Releasing a hold credits the wallet and only then forgets the hold, so the money is provably back before the record of it goes — a refund that gets refused has to leave the hold recoverable. But crediting a wallet raises
OnWalletChangedsynchronously, and a handler running at that moment sees a hold that still looks live. A handler that released it again — "the player cancelled, drop my pending hold" is ordinary code — was paid the refund a second time for an amount that left the wallet once. Committing it instead reported the same money as both returned and spent, and a second expiry sweep started from such a handler did the same. A hold is now marked while its refund is in flight, and releasing, committing or expiring it again during that window is refused. The marker is scoped to the call, so a refused refund can still be retried once the wallet has room. -
Currency — the expiry pump attached by
WithEscrowAndPumpswept nothing. The factory built an escrow stack, attached a pump, and handed the stack back without ever introducing the two. The pump went looking for a service in the scene instead — and the stack it belonged to was not there, because these factories return a stack rather than installing one. Unless the caller separately published it, the pump resolved the bareSceneCurrencyService, which carries no escrow, and retried every frame forever. Nothing failed and nothing was logged: every TTL configured through this factory simply never fired, while the money those holds reserved stayed debited. The factory now points the pump at the stack it just built. Pumps placed by hand resolve from the scene exactly as before, and building two stacks against one host now reports that only the second is swept rather than dropping the first silently. -
Economy — a failed purchase could refund more money than it charged.
BuyandCrafttake the money first and hand it back if a later step fails, and what they handed back was the price. UnderLedgerPreflightMode.PolicyApprovedthose are not the same number: that mode exists so a policy may adjust the charge, so a minimum-balance floor can clamp a debit of 10 down to 5 and the purchase still goes through. If delivery then failed, the buyer was refunded the full 10 having paid 5 — reported as a failure, and 5 gold better off than before they tried. A full inventory fails that step every time, so it repeated for as long as the player kept clicking. Both services now refund what the ledger actually took, measured rather than assumed. The defaultStrictmode was never exposed to this: it refuses a charge the policy would adjust before any money moves. -
Editor — Cleanup Orphaned Content could not see Loot. The orphan scanner keeps its own list of systems, and Loot was never added to it. Delete
Runtime/Systems/Lootand the samples, teaching, editor and integration content it leaves stranded was never offered for cleanup, because nothing looked for it — the tool reported the project clean while five folders sat orphaned. A test now derives the expected systems from the folders on disk, so a system added to the framework fails it until the scanner is told about the system too. -
Cooldowns — a haste or slow buff removed at the wrong scale left the actor permanently faster.
CooldownMultiplierSink.PushMultiplierraised anything below0.01to that floor, whilePopMultiplierdivided by the raw value and skipped the division entirely for anything at or below zero. So a push/pop pair did not cancel: pushing and popping0.005left the accumulator twice as high as it started, and pushing and popping0stranded the floor in it. The drift was invisible until it wasn't, because the factor resets to1once the last modifier is popped — it only showed while another buff was still held, which is exactly when several are in play. Push and pop now apply the same floor, so any pair cancels exactly. -
Editor — moving the framework folder stopped define sync from running at all. 1.2.0 already taught the presence checks to discover the framework root rather than assume
Assets/RevFramework. The import hook that decides whether a sync happens still matched hard-coded paths, so on a relocated install the corrected checks were never reached — the same symptom as before, one step further back. The hook now watches whatever the discovered root is.
It was also a list that had gone stale: it named seven systems and never gained Loot, so installing or deleting Loot triggered no re-sync even in a normal install. Watching the root wholesale removes the maintenance trap along with the assumption; the sync is debounced and writes only when the resolved symbol set actually differs.
- All systems — one throwing event subscriber no longer silences the subscribers registered after it. The C# event guards in most systems delivered caller isolation and called it per-listener isolation: a throwing listener could not reach the operation that raised the event, but the throw still ended the raise, so listeners registered later never heard about a change that had already committed. A HUD binder that threw cost a quest tracker its wallet event, subscription-order dependent and silent.
Wallet, audit and batch events in Currency, the crafting lifecycle notifications, Health's damage, heal, death, damage-attempt and combat-state events, Status Effects' applied, refreshed and expired notifications, and the Pickups fail and shield-depletion events now invoke each subscriber under its own guard — matching what Loot, Inventory and the save manager already did. A throwing subscriber is still reported and swallowed; everyone after it still runs.
Designer-wired UnityEvents keep a single guard around the whole invoke, because Unity offers no way to enumerate a UnityEvent's runtime listeners. The guard comments now state that boundary plainly instead of claiming per-listener isolation they cannot provide.
-
Health — a destroyed shield went on absorbing damage, and a destroyed invincibility handler made its target immortal. Unity reports a destroyed component as null through the
==operator onObject, and an interface-typed field never reaches that operator — so_shield != nullstayed true long after the shield was gone. The built-in shields absorb out of managed fields only, so nothing threw: the dead shield simply kept eating damage. The same hole sat under invincibility, where a handler destroyed whileIsInvinciblewas true left the target unkillable, and under the death handler and regen hooks. All five reads now drop a destroyed collaborator, the same way the health bars were fixed in 1.2.0's earlier pass. -
Health —
RefreshOptionalComponents(), so a collaborator added at runtime is picked up. Invincibility, regeneration and death handlers were resolved duringAwakeand again on revive, and nowhere else — so anIInvincibilityHandlergranted by a power-up, or a regenerator that arrives with a perk, sat attached in the Inspector doing nothing until the next revive. The framework's own teaching panel worked around this by re-binding every tick. The rule hubs and before-death handlers already re-fetched themselves and needed no change. -
Inventory — a destroyed authority no longer gates mutations, and
RefreshAuthority()picks up a late one. The authority is interface-typed too, so a destroyed one was consulted forever from its stale state — a gate that had stopped meaning anything. It is now dropped and re-resolved at the point of destruction. Separately, a service that found no authority atAwakeis permissive by design, which left a project publishing its authority later — a network bootstrap running after the scene's services — with an ungated inventory;RefreshAuthority()is the explicit way to say when it is ready. Deliberately a call rather than a per-mutation lookup, because resolution ends in a scene-wide scan and paying that on every give and take is the worse trade. -
Inventory — containers created for owners without
CharacterInventoryno longer leak. Any GameObject handed toGetgets a container, but only owners carryingCharacterInventoryare cleaned up when they die, because that component carries the hook that does it. A chest, a corpse or a destructible crate left its entry behind for the lifetime of the service — holding the destroyed GameObject alive and permanently unreachable, since every read path rejects a destroyed owner.ClearForOwnernow collects dead owners as it goes, and the newPruneDestroyedOwners()reclaims them on demand for scenes that spawn such objects in volume. -
Crafting — adapters no longer go permanently inert when their service arrives late. All three crafting adapters snapshotted the inventory or currency service in
OnEnableand never retried, so a service published later in the frame — or by an additively loaded scene — left crafting failing on currency or inventory for the lifetime of the component, with nothing to say why.CurrencyResolvedeliberately does not cache misses precisely so a late service is found; this is the half of that contract the adapters were not keeping. They now retry only while the dependency is null: a live service is never swapped, because a currency hold is placed against one escrow and captured against whatever is resolved afterwards, and reservations hold a context across reserve and commit. -
Docs — four guarantee-matrix claims corrected to what actually ships. The save matrix still told readers restore order against Inventory and Currency was theirs to get right by convention —
CraftingSaveParticipanthas declaredRevSaveOrder.Latesince the coordinator gainedIRevSaveOrdered, so the ordering hazard the matrix warned about is enforced in code; the danger box now says so instead of assigning homework. The crafting matrix denied completion idempotency across repeated loads, which the participant's restore has guaranteed since the quickload fix. The Economy matrix opened with "Depends on Currency — No", contradictingmodules_dependencies, the assembly's own reference list and its define constraint; the seams being swappable is the true part, and the row now says both halves. And the Samples README promised optional Netcode samples and aNetcodeSamples/folder that have never existed anywhere in the package. -
Crafting — modifiers can compose: adding an extra output no longer erases the ones before it.
CraftAdjustments.EnsureExtraOutputsandEnsureChanceOutputsreset the list they ensured, so when two modifiers contributed outputs — a station bonus and a chance output, say — whichever ran second erased the first's contribution, last-writer-wins in component order, silently. The service starts every computation from a fresh instance, so the reset protected nothing. Ensure now means ensure: the list exists and earlier contributions stay. A modifier that genuinely wants to replace what came before can clear the list itself, which makes the replacement its own visible decision. -
Loot — an adapter assigned at runtime is now picked up, as the docs always said it was. Two documentation pages promise that binding re-resolves at the head of every public call so runtime assignment works; the service actually resolved once per component lifetime, so an adapter wired after the first roll — delivery enabled after a tutorial, adapters bound by a late bootstrap — was silently ignored forever. Binding now re-resolves whenever an assigned component changes. The fast path is three reference comparisons, and misassignment warnings fire on change rather than on every call.
-
Docs — Loot's zero-quantity claims now match the roller. Seven places — the FAQ's top troubleshooting list, the Overview's "one thing that will catch you" callout, the guarantee matrix, the mental-model checklist — said a quantity of zero awards nothing. The roller has always treated anything below one as one, deliberately (an entry added in the inspector and not yet filled in does something visible instead of silently never appearing), and the guarantee matrix said so correctly one line above the row saying the opposite. The pages also claimed a new entry defaults to
1; the field starts at0and the roller corrects it at roll time. All seven now state the actual contract: express "sometimes nothing" with aNothingentry or a chance, never with a quantity. -
Status Effects — a boot-order race could erase the Regen and Shield statuses for the whole session. The registry's boot hook clears its map before re-registering built-ins, and the Health integration registers Regen and Shield from its own hook at the same load tier in a different assembly — an ordering Unity deliberately leaves undefined. When the integration happened to run first, the registry's clear erased both registrations, and every id-based build of a regen or shield status failed until the next domain reload, silently.
Integrations now hand the registry a registrar it replays after every rebuild, so both boot orders converge on the same registrations. StatusRegistry.AddIntegrationRegistrar is the supported way for integration assemblies to add entries; registering directly from a same-tier boot hook is what made the outcome depend on assembly load order.
-
All systems — moving the framework folder no longer silently strips every
REV_*define. Define sync hard-codedAssets/RevFrameworkinto all eleven presence checks, so relocating the folder — intoAssets/ThirdParty/, say — made every check false at once: all nine systems present, every one compiled out, nothing logged. The framework root is now discovered from the Core asmdef, which ships in every SKU, and the checks resolve against wherever it actually lives. -
Input — the bundled input service no longer reports authoritative silence it never measured. Two faults with one shape. With the Input System package installed but the legacy handler active,
Keyboard.currentis null — yet the service's Input System path still claimed authority, so every key and axis read dead and the legacy fallback its own documentation promises never ran. And its key map covered ~31 keys, so aninteractKeyoutside them —T,Tab,Alpha1— silently never fired even with the Input System genuinely running.
The Input System path now answers only when it actually read a device: no device, or a key it cannot express, falls back to legacy input per query — the behaviour the class has documented all along. The key map now covers the full keyboard (letters, digits, numpad, function keys, punctuation, navigation, locks and modifiers), and keys the Input System has no control for decline cleanly instead of reading as never-pressed.
-
Loot and Inventory — a swallowed subscriber exception is now reported in release players. Both systems already isolated subscribers per listener, but logged the swallowed exception through a diagnostics helper that compiles out of release builds — so in a shipped game the failure was swallowed and unlogged, invisible everywhere. Both now report through
Debug.LogException, the same unconditional channel every other system uses. -
Inventory — transferring an item between owners stripped its durability and metadata.
TransferResultmoves by GUID, and it built the stack it delivered from the item definition rather than taking the one the source held. Durability and metadata live on the stack, so a worn, named sword arrived pristine and anonymous — and since the original was removed from the source in the same operation, the data was gone rather than copied. The call reportedOk.Movenever had the problem, because it moves the stack itself.
The rebuilt stack caused a second, louder failure that had never been connected to it. Stacking is strict about durability and metadata, so a stack rebuilt at durability zero could not merge with a destination stack that had real values: transferring into a container that already held that item failed with NoSpace unless an empty slot happened to be free. A transfer into a merchant already carrying the same worn item simply refused.
Transfers now collect the units from the source's own slots, keeping each slot's data with the units taken from it, and remove them from the slots they came from — removing by GUID could take from a different slot than the one whose durability was handed over, leaving the wrong item behind. Two slots of the same item with different durability therefore transfer as themselves and arrive as two stacks, which is what strict stacking says they are.
Result codes are unchanged for every case that already worked, including the existing partial and rollback behaviour, and the whole Inventory suite passes untouched.
- Crafting — loading a save taken mid-craft destroyed the craft if it had since finished. Quicksave while something is crafting, keep playing until it completes, then reload that save: the job was dropped instead of restored. Inventory and Currency rewound to before the craft finished, so the player was left with no job, no outputs, and the inputs still spent — and the only trace was a log line. This is the ordinary shape of a quickload rather than an edge case.
The cause is a guard doing its job on the wrong input. Crafting records which completion ids it has applied so that restoring one snapshot set twice cannot deliver twice. That record is in memory and does not rewind when a save is loaded, and it cannot tell a completion applied by an earlier restore from one a live craft applied a moment ago — a job's completion id is deliberately stable across save and restore, so both name the same id.
CraftingSaveParticipant now clears the record immediately before restoring, which is the one place that knows a load is a rewind of the whole world rather than a replay. Everything the guard was built for still holds: an offline completion reconciled during that load is not reconciled twice, and repeated RestoreJobs calls inside a session are still a no-op. Hosts driving RestoreJobs themselves want the same ClearAppliedCompletions() call, and the Crafting README now says so with the reason attached.
- Economy — a purchase refused by policy reported that the buyer could not afford it. With a currency policy attached and the default
LedgerPreflightMode.Strict, a wallet holding 100 was refused a price of 10 withInsufficientFunds. The refusal itself is correct and deliberate: the policy — a minimum-balance floor inClampmode — would have charged 5 rather than the 10 on the label, andStrictexists to refuse a charge that differs from the price. What was wrong is that it said so by naming the one thing that was not the problem, sending anyone debugging it to look at balances. It now reportsPolicyBlockedwith a message naming the two amounts and the mode that refused them.
Nothing about what gets charged has changed, in either mode. PolicyApproved still lets the policy-adjusted amount through, Strict still refuses it, and CanPay still returns exactly what it returned before — panels grey the same buttons.
The surrounding documentation was wrong in a way that made this hard to see. LedgerPreflightMode described itself as controlling "UI/UX preflight behaviour only", IValueLedger.CanPay as "UX preflight only", and the guarantees matrix as not changing authoritative behaviour — while the built-in ShopService and CraftingService both refuse the transaction when the money preflight fails. A true from CanPay is advisory and Pay may still fail; a false cancels the sale. That asymmetry is now stated everywhere it was denied, including for anyone writing their own IValueLedger, where returning false cancels sales rather than greying out a button.
No public API changed. The services ask for a reason through an internal capability the built-in ledger implements; a custom IValueLedger has only a bool to offer and takes the original path, InsufficientFunds included.
-
Economy — a currency policy charged the balance left over instead of the price.
TryComputeEffectiveDebitreports the absolute balance a wallet is left holding after a debit. The economy's policy adapter filed that straight in as the amount to take, so with any policy attached a 100 gold item bought against a 1000 gold wallet debited 900 — and when the wallet exactly covered the price, the leftover was zero, the line was dropped, and the purchase completed free. An unmatched rule passes the balance through untouched, so a policy authored for one currency corrupted charges for every other currency too. The debit is now the measured difference, matching the conversion the currency hold transaction already performs at the neighbouring call site. -
Pickups — a pickup destroyed itself whether or not its payload was delivered.
PickupEffect's apply path returned nothing, so a full bag, a cancelling decorator, a missing service and a clean grant were indistinguishable to the caller — and the caller isTriggerPickup, deciding whether to destroy the pickup. Walking over an item with no room consumed it and the item with it.GiveItemEffectalready knew:GiveExactreturns a result, and it only logged it, in the editor.
An effect that can be refused now implements IPickupEffectReportsDelivery, and TriggerPickup leaves the pickup alone when the payload is refused, so the actor can come back for it. This is a separate interface rather than a change to OnApply, which is protected abstract and implemented by every effect a project has written — changing its signature would break all of them. ApplyTo is unchanged and still returns nothing; TryApplyTo is the reporting path. An effect that does not opt in is assumed to have applied, which is what the void path assumed silently.
A throwing effect is still consumed exactly as before: the exception surfaces as a bug rather than retrying on every physics step.
- Crafting — escrow crafting blamed the player's materials for a capability the framework does not have.
TryCraftImmediateEscrowneeds an inventory adapter that can reserve inputs and output space. Inventory exposes no atomic reserve surface, so the shipped adapter implemented that seam and declined every call. The escrow path already refuses cleanly withNoAdapterwhen the seam is absent — implementing it and then saying no defeated that check, so the craft failed further in withConsumeInputsFailed, pointing at inputs that were sitting right there.
No reason code was added — the accurate one already existed and was being bypassed.
The tier now works with the adapters that ship. InventoryCraftingAdapter reserves inputs and output space, and CurrencyHoldCraftingAdapter maps the hold seam onto the currency escrow instead of declining on a component named for holds.
Both reserve by doing: the mutation happens when the reservation is taken, Release undoes it, and TryCommit only finalises. That mirrors EscrowCurrencyService, which debits at hold time and treats commit as a no-op, and it is what the escrow path requires rather than a preference — the service unwinds nothing if an output commit fails, because by then the inputs are committed, so a reservation whose commit could fail would destroy them.
Two limits worth knowing. Escrow crafting remains immediate-only, so reservations never outlive the call — long-running escrow needs them persisted across save/restore, which is still a separate feature. And currency holds need the composed currency stack to actually have an escrow layer; without one the craft declines having consumed nothing. - Crafting — a refund the wallet refused vanished without a word. Every credit crafting issues is a refund: money it already took and is handing back, on a cancel, a failed delivery, or a charge that clamped short. ICraftingCurrencyAdapter.Credit returns nothing, so a cap, a ceiling or an authority declining that credit was indistinguishable from success — the owner was left short and no build, editor or otherwise, said anything. The shipped adapter made it worse by discarding a result it already had: the currency service's own Credit returns one.
An adapter can now also implement ICraftingCurrencyCreditReporter, and crafting uses it when present. Both shipped adapters do, so the default stack reports refusals immediately. This is a separate interface rather than a change to ICraftingCurrencyAdapter because that one has shipped — adding a member would break every adapter a project already wrote — and it follows the same shape as IAuditAwareCurrencyService in the currency system. Reporting only: a refused refund is still refused, but somebody finds out. - Status Effects — fire immunity did not stop burning. A burn tick reached Health as a bare damage call carrying no damage type, so a fire resistance or a fire immunity had nothing to match against and both were ignored. Poison was the same.
Status Effects cannot classify the damage itself: it references only Core, and referencing Health would end the guarantee that it compiles with Health deleted. The Health integration now installs a route that turns the status id — which the tick already carried and then discarded — into a damage type, and applies the tick through the health pipeline. Burn maps to fire, poison to poison, and a status with no matching damage type stays untyped rather than being given an invented one.
No attacker is supplied: a damage-over-time tick has no live attacker when it fires, and inventing one would misreport who dealt it. A target without the health mutation interface keeps the previous untyped call rather than losing the tick.
- Health — a regeneration delay of zero stopped regeneration instead of starting it.
regenDelayaccepts zero, on the field and throughConfigureTiming, and zero plainly means "begin regenerating immediately". It meant the opposite: the cooldown tick read a zero timer as "not in cooldown" and stopped regen, and because that tick only runs while regen is inactive it re-stopped it every frame. Regeneration never began at all.
The timer could not express the difference on its own — it reads zero both before a zero-delay start and after any stop — so a pending flag now carries it. That distinction is load-bearing: without it, treating a zero timer as "start now" re-activates regen after the ceiling has stopped it, since at the ceiling current health is still below max. Activation also checks there is something to heal, so a zero delay on a full-health target simply stays inactive.
- Inventory — a throwing listener could abandon an operation half-done.
OnContainerChangedwas raised with a bare invoke, from the container's own change notification, which fires part-way through anything touching more than one slot. An exception from a subscriber therefore travelled out through the mutation and into the caller ofGiveExactorMove, and could leave a multi-slot operation partly applied. A UI binder reading a slot it has not refreshed yet is the ordinary way that happens, not an exotic one. One throwing subscriber also silenced every listener registered after it.
Both raise sites now isolate subscribers individually, the same shape the loot service uses. A listener that throws is reported and skipped; the operation that notified it completes, and the other listeners still run.
- Pickups —
UnifiedPickup2Dfollowed none of the pickup system's own rules. It handles its own trigger rather than going throughTriggerRelay2D, so every guard the relay andTriggerPickupprovide has to be repeated in it — and none were. It had no same-step latch, so a multi-collider actor collected once per collider and one pickup paid out several times. It had no layer or tag filter, so anything entering the trigger collected it. It ignored the authority gate. It granted to the collider's own GameObject rather than the acting object, putting items in a container on a child that the actor's own UI never reads. And it discarded the grant result and destroyed itself unconditionally, so a full bag consumed the pickup and the item with it.
All five now match TriggerPickup, including its throw semantics. Filters default to everything allowed with no required tag, and authority resolves to none unless a project configured one, so an existing scene behaves as it did except where it was losing items.
- Status Effects — a shield granted nothing on the configuration recommended for servers.
ShieldStatusgranted its shield insideOnApplyFX, and that hook is skipped entirely when aStatusEffectControllerhas its global FX toggle switched off — a setting its own tooltip recommends for server-only objects. The status applied, ticked and could be dispelled, and absorbed nothing. Nothing reported it.
A shield is gameplay, not presentation, so the grant moves to Apply, which always runs and pairs with Remove, which already took the gameplay path. Applying the same instance twice also granted twice, stranding the first ticket in the pool with nothing holding its handle; it now grants once.
Integrations/StatusEffects/HealthIntegration had no tests at all. It has a suite now.
-
Economy — a minimum-balance policy could make a purchase free. Separate from the mis-charge above and reachable even once it was fixed: a Clamp-mode floor could shorten a debit all the way to zero, so a wallet already sitting on its floor bought anything at no cost. The line computed to zero, was dropped, and the purchase reported success having taken nothing. A price line only reaches the policy computation with a positive amount, so a zero effective debit can only mean the floor refused the whole charge — it is never a genuinely free item, which carries no money line at all. That now fails with
PolicyBlocked. Partial clamping is unchanged: a floor that permits part of the debit still succeeds with the smaller amount. -
Inventory — splitting a stack onto a partly-full slot created items. An explicit-target split commits the add to the target before deducting the source. When the target could only take part of the split, the operation was refused with
NoSpaceand the accepted part stayed put while the source kept its full quantity, so the container finished holding more than it started with. Two routes reached it: a target stack with less room than the split, and an empty target when the split exceededmaxStack. The partial merge is now taken back before the refusal is returned. -
Currency — adding caps to a service turned a failed transfer into a silent partial one. The transfer preview reports "policy shrank this" and "the wallet is short" the same way, and the capped decorator forwarded both, so transferring 100 from a wallet holding 50 moved 50 and returned
Ok.Debiton the same service and the undecorated transfer both refuse that. A transfer the source cannot fund now fails withInsufficientFunds. Policy-driven reductions are unchanged: a destination cap still discards the overflow and succeeds with the smaller amount. -
Health —
Diedcould be raised twice for one death. The death flow clears its guard before consulting the before-death seam, and that seam runs component code documented as free to mutate the target. A handler that re-enteredKill()finalised the death in the nested call, and the outer one then finalised it again — twoDiedevents and two death-handler runs for a single death. -
Health — a health bar froze permanently when its target was destroyed. Both the UI connector and the world-space bar hold the resolved target as
IHealthReadonlyand tested it with a plain null check. Unity reports a destroyed object through the==operator ofUnityEngine.Object, which an interface-typed reference never reaches, so a destroyed target read as present: neither re-resolved, and the bar kept the dead target's last value for the rest of the session. Respawn is the ordinary way a target is replaced, so this was the common path rather than an edge case. -
Crafting — save and reload completed a queued job instantly. Offline progress credited the time since a job was accepted, which for a job that was only ever queued is time spent waiting rather than time spent crafting. A backed-up queue therefore finished the moment a save was reloaded. Offline elapsed now applies only to a job that was already running when it was saved; a queued job restores with its full timer and stays queued. The rest of the system already agreed — the craft clock is stamped when a job actually starts — and only this path disagreed. The guarantee matrix, public API page and system README now state the rule.
-
Crafting and Loot — switching a modifier off in the Inspector did not switch it off. Both systems gather modifiers with
GetComponentsInParent(includeInactive: true)and neither checked whether the component was enabled, so unticking anAddChanceOutputModifier, aSimpleStationBonusModifieror a customILootModifierleft it modifying crafting output and loot rolls exactly as before. Unity's own convention is that a disabled component does not run, so the one control a designer reaches for did nothing.
Crafting validators had the identical fault one method away, and it was worse there: a validator that will not switch off blocks crafting outright rather than quietly altering it.
Disabled components — and destroyed ones, which the same list could hold — are now dropped. includeInactive stays, because the two exclusions mean different things: an unticked component was switched off deliberately, while a modifier on a parent that is not active yet was switched off by nobody and still belongs to its owner.
- Inventory — a context object with no script on it lost its scene.
InventoryResolve.ServiceFrom(GameObject)reached the scene-preferring search by way ofGetComponent<MonoBehaviour>(), so a bare context — an empty anchor, a marker object, one carrying only native components such as a Collider — produced a null MonoBehaviour, which the other overload reads as "no context at all". The scene was discarded and resolution fell through to the global search, which cannot see a service whose GameObject is not active yet.
Resolution now takes the scene directly, so both overloads use the context they were given. Callers passing a GameObject that does have a script are unaffected: that path resolved the same scene before.
- Status Effects — recomputing potency could skip an effect, silently.
RecomputePotencyForAllwalked the live active list while calling out to potency providers andSetMagnitudeScale, both of which are customer code. "Recompute potency, and remove the status if it falls to nothing" is an ordinary aura design, and removing an effect that sits ahead of the cursor shifts every later one down past the index about to be read — so the last effect was never rescaled and nothing reported it.
It now snapshots the active list and re-checks membership per entry, which is what its sibling RecomputeDurationsForAll already did for the same reason.
-
Currency — a deficit could not be handed straight to the formatter.
GetDeficitreturns(CurrencyId, Money)lines andFormatDeficitaccepted(CurrencyId, long), and no conversion bridges them, so the two helpers that plainly exist to be used together could not be.FormatDeficitnow has an overload takingGetDeficit's own output. Added as an overload, so no existing call changes. -
Currency — resolution cached a miss it could never read back. A failed scene lookup was written to the cache, but the read path requires a non-null entry, so the write grew the dictionary and changed nothing while every resolve still rescanned. Only a resolved service is cached now. Not caching misses is also required behaviour: a service added after a failed resolve has to be found, and nothing signals that one appeared.
Deprecated¶
- Core — four more seams that were never wired to anything are obsolete and will be removed in 2.0.
ICooldownService,ICoroutineRunner,IObjectFinderandIResourceLoadershipped as public contracts that nothing in the framework accepts, calls or returns, so implementing any of them changed nothing — the same defect asIEventBus, found by the same question and given the same treatment. Each now carries an obsolete notice naming what the framework actually does instead: Pickups owns its cooldowns throughPickupEffectStaticCooldowns, timed behaviour runs fromUpdateon the component that owns it, each system resolves dependencies through its own scene-scoped resolver, and framework assets are authored references assigned in the Inspector rather than loaded by path.
SceneCooldownService's documentation was the worst of it — it invited you to replace it with your own ICooldownService implementation, which would have been a genuine afternoon spent wiring into a seam with no other end. It and UnityCoroutineRunner are obsolete but still work when called directly; their notes now say plainly that nothing consults them.
The unreachable internal types behind the rest have been removed outright, matching what happened to InMemoryEventBus: SceneObjectFinder, RuntimeResourceLoader, CooldownUtility and FindCache had zero references anywhere in the framework, including tests.
- Core —
IEventBusis 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 internalInMemoryEventBusthat 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 —
IPrefabSaverremoved from the Core and UnityIntegration READMEs. No such type has ever existed in the framework.
Internal¶
No runtime behaviour changes. Recorded because these items change what the release gates catch.
- Ten assembly-definition references pointed at assemblies that have never existed, and a new gate stops more appearing. Unity silently drops a by-name reference it cannot resolve — the same mechanism the deliberate per-SKU gating relies on, which is exactly why these were invisible: a typo and an intentional optional reference look identical in the editor, and both compile.
Four asmdefs referenced RevFramework.Currency.Abstractions, two RevFramework.Inventory.Abstractions and two RevFramework.Health.Abstractions. None has ever been an assembly; those namespaces live inside the parent system assemblies, which were already referenced. Two Crafting test asmdefs referenced RevFramework.Common.UI — a typo for Common.UX — and no test used its types, so nothing complained. All ten removed; nothing changed but the noise.
A packaging test now resolves every by-name reference against the assemblies that actually exist, with a short explicit allow-list for genuine externals (UnityEditor, Unity.TextMeshPro). It scans the asmdef JSON rather than source text, so it cannot match its own source the way two earlier fences here did, and it asserts it found a plausible number of files and references first — so a broken parser fails loudly instead of reporting a clean repo.
REV_ITEM_USE_PRESENTis documented rather than deleted, and the release checklist corrected. It is written into scripting defines and consumed by nothing in the framework, which reads like a dead define. It is not:Runtime/Systems/Inventory/Useis self-contained — nothing else in the Inventory assembly references its types — so a buyer can delete that folder and gate their own code on#if REV_ITEM_USE_PRESENT. Removing it would strand the symbol in the PlayerSettings of everyone who already has it, so the entry now carries a comment saying why it stays.
The release checklist claimed ITEM_USE was "enforced at the asmdef level via defineConstraints". No asmdef anywhere mentions it. Corrected.
- Three coverage debts paid, and one of them explains why a bug existed at all.
CraftingWorkbench2Dhad no test, which is exactly how it drifted apart from the 3D bench and lost two fixes the 3D one had. It is covered now: a collider that is not the player's can no longer spend the player's craft cooldown without the suite noticing.
An earlier attempt at that test failed with TargetException: Non-static field requires a target and was abandoned undiagnosed. The cause turned out to be one line of Unity trivia — the bench declares [RequireComponent(typeof(Collider2D))], Collider2D is abstract, so Unity cannot satisfy it and AddComponent on a bare GameObject returns null. The reflective write was merely where the null surfaced. The fixture now adds a concrete collider first and names that failure if it ever recurs.
Also covered: CraftingOfflineProgressPanel's Clear Active and CurrencyPolicyCapsPanel's Apply, both of whose reporting was corrected this release; and HealthAuthority's scene cache, which was the one site of the Scene-key boxing fix that could not be pinned at the time.
The policy panel's test asserts which service method was called, because nothing else can tell the two apart: a clamped credit and an absolute write to the clamped value leave the same balance behind, which is exactly why the substitution survived a release unnoticed. Health was the only system in the framework with no InternalsVisibleTo friend assembly, so nothing could reach it. It has one now, matching the other five systems and using the same wording.
- The one-shot pickup guarantee is now pinned, and it turns out to be Unity's rather than ours. A pickup set to destroy on use latches duplicate consumption against the current physics step, and
Destroydoes not take effect immediately — so if a frame long enough to run two physics steps left the object alive across both, a second collider entering on the second step would be granted a second payload. The reasoning is sound and the conclusion is wrong: measured on 6000.3.5f2, Unity flushes pending destroys between physics steps, not at the end of the frame, so the pickup is already gone by the next step and the window does not exist. No behaviour changed.
A test now measures that flush directly rather than leaving it assumed, because the framework's guarantee depends on it: if a future Unity moves the flush to the end of the frame, the suite says so and names the two components — TriggerPickup and UnifiedPickup2D — that would then need a once-ever latch on their destroy path. Reproducing it at all needs a deliberately slow frame; the fixture asserts it got one, verified by removing the slowdown, where the two entries land twelve frames apart and the guard fails instead of the test passing on nothing.
- A test assembly can no longer run without being counted. The fix above made the count fence's premise true by hand — the four assemblies that ran without opting into
TestAssembliesnow declare it — but left the premise itself unguarded: the next assembly added without the opt-in would run and go uncounted exactly as those four did, and both the fence and the document would report green.
A second test closes it from the other end. Instead of scanning asmdef text it asks the domain which assemblies contain test methods — [Test], [TestCase], [TestCaseSource] and [UnityTest], because a fixture built entirely from [TestCase] methods has no [Test] anywhere on it — and requires each one under Tests/ to carry the opt-in. An assembly that skips it is now a failing test naming the assembly and its asmdef path, rather than a figure quietly going stale. Verified by removing the opt-in from one asmdef: it names that assembly and nothing else.
Only the undercount direction is asserted, because the other one is legitimate — Economy.Tests.Fakes declares itself a test assembly and holds no tests, which is why the published assembly figure is one higher than the number of assemblies that run.
UnifiedPickup2D's same-step latch is covered again, and the published test counts are measured rather than remembered. The latch — one pickup paying out once however many of a multi-collider actor's colliders enter in a single physics step — had its test deleted after failing twice in CI in a way that could not be accounted for at the time. It can be now, and neither the latch nor the clock was at fault: the fixture built the actor and the pickup on top of each other, so Unity's own physics fired trigger entries alongside the ones the test drove, and delivered a second payload on the following step, where a re-armed pickup is supposed to pay out. The test then counted both and blamed the component.
The pickup is now built away from the actor so the driven call is the only delivery route, a probe asserts Unity's physics contributed nothing, and the count is read with no frame boundary between the two entries — so they are in the same step by construction rather than by timing luck. A re-arm test alongside it doubles as the control: the same second entry pays out once a step has passed, so the refusal in the first is the latch and not a pickup that simply never grants twice. Verified against a latch-less build, where exactly that one test fails, 4 delivered against 2 expected.
TESTS.md published 1817 EditMode and 267 PlayMode tests; a measured run of both suites gives 2032 and 322, all green. The test counts cannot be derived from source, so nothing was watching them.
The assembly counts have a test guarding them, and it was undercounting while reporting green. Its own remarks claimed that opting into TestAssemblies "is the same condition the test runner uses, so this cannot drift from what actually runs" — it isn't. With overrideReferences: false the NUnit assembly is referenced anyway, so a test assembly that never mentions TestAssemblies still compiles, and the runner takes any assembly holding tests. The Pickups and StatusEffects Hostile and InternalTruth suites had been in that state: 289 tests running, none of them counted. The four now declare the opt-in, so the scan's premise is true rather than merely convenient, and the EditMode figure goes from 25 assemblies to 29. The remaining hole — an assembly added without the opt-in would run uncounted again — is now written down where the next reader will find it instead of being denied.
The CI floors move with them, to 1900 and 290. At the previous EditMode floor of 1700 the suite had grown to where losing the largest assembly to a missing define left 1711 tests and passed — the exact disappearance the floor is sized to catch. A floor that no longer catches the case it was built for looks identical to one that does.
- The scene smoke test no longer fails an arbitrary scene when TMP Essential Resources are absent. Unity logs that message once per session, so it landed on whichever sample scene happened to be loading when TMP first initialised — meaning the fixture blamed a scene that had nothing wrong with it, and only sometimes. Four PlayMode runs on identical project state went pass, pass, fail, pass.
TMP Essential Resources are a per-project asset import and every TMP-dependent file is #if TMP_PRESENT gated, so their absence is a supported setup rather than a scene defect. The message now goes in the fixture's existing AllowedSubstrings list, which is the extension point it documents for exactly this, matched narrowly enough that a genuine TMP runtime error still fails.
A new test pins the entry against the verbatim message, with controls proving the list does not swallow a NullReferenceException or a framework error. Both ways an allow-list drifts are silent: an entry that stops matching reopens the flake, and one that matches too much hides the defects the fixture exists to catch.
An intermittent failure in a release gate is worse than no gate, because it trains you to re-run it. This one cost most of an hour being investigated as a regression in an unrelated change.
- The snapshot renderer now marks protected members as protected. It had always captured them — the header says so, and protected is real contract for anything subclassable — but it never said which was which, so a public member and a protected one rendered byte-identically. Demoting a public member to protected breaks every caller outside a subclass, and it produced no diff at all: the gate was blind to the change precisely because it was rendering the member perfectly well. The same shape of hole as the
[Obsolete]-on-a-type gap below, and found the same way — by asking what a contract change could look like that the renderer would happily agree with.
Visibility is now emitted for methods, properties, events, constructors and fields. Constructors and fields needed it separately: neither goes through the shared modifier helper, so a protected constructor — the difference between a type you can instantiate and one you can only derive from — was unmarked by its own route.
Three goldens move, all of them rendering-only: Inventory, Pickups and StatusEffects, at 53 lines changed with 53 replaced and none added or removed. Pickups is the clearest gain — PickupEffect.OnApply is the method you override, and it used to read exactly like one you call. Members also now sort public-before-protected within each kind, so a type reads its callable surface first.
RevFramework.Core.Abstractionsis 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 publicIEventBuswith no implementation and no consumer sat in the shipped surface with no gate remarking on it.
RevFramework.Loot joined on landing, so the newest system's surface is guarded before its first customer has it. RevFramework.Common and RevFramework.Common.UX 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.
Migration¶
- A disabled PRE rule in Health now really is disabled. If a project relies on a rule component that is unticked in the Inspector still applying — mitigation from a disabled
ArmorRule, say — that rule must now be enabled, or its effect moved elsewhere. This only affects rules deliberately left attached-but-off; the POST stage already behaved this way. - A damage preview reports the non-critical figure.
CritRuleno longer rolls during a preview, so a UI that previewed repeatedly and occasionally showed a doubled number will now show a stable one. This is the conventional behaviour for a damage preview, and it is the only honest answer available without spending the roll the real hit needs. LastDamageReport.Requestedchanges value where a PRE rule rewritesRawAmount. If you built a damage meter or combat log against the old behaviour and compensated for the under-reporting, that compensation now double-counts.Appliedis unchanged.StatusAuraZone.refreshOnEnternow defaults to false. Zones already in your scenes keep whatever value is serialised on them — this changes only newly added components. If you were relying on the old default to refresh durations on entry, tick it explicitly.StatusAuraZone.vulnAmounthas changed meaning and range, from[0, 1]defaulting to 0.25 to[1, 4]defaulting to 1.25. It is a damage-taken multiplier, so the old range could not express a vulnerability at all — every value collapsed to 1.0. A zone already in your scene keeps its serialised value, which will be clamped up to 1.0 and continue doing nothing until you set it. Check any zone using the Vulnerability sample.- Pickup prefabs created by the old tooling stay broken. The fix corrects the tools, not the assets they already produced. A prefab created before this release will have no trigger relay and — if it came from the Pickup Creator wizard or the Create Prefab From Definition menu — an empty effect field. Re-create those prefabs, or add the missing relay component and assign the effect asset by hand. A prefab that already works is unaffected.
- Effect assets generated for status pickups may need regenerating. See the note under the StatusEffects entry when that work lands.
[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.
RevSaveCoordinatorcomposes any number ofIRevSaveParticipants 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
ICurrencyWalletQuerywithHasWallet(GameObject)andTryGetHeldCurrencies(GameObject, List<CurrencyId>), implemented bySceneCurrencyService. Purely additive: it is a separate optional capability rather than new members onICurrencyService, 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.
EconomyWithInventoryPanelalready demonstrated rollback, but every failure in it was injected: a wrapper aroundIValueLedgerorIItemStorethat 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 walkedSlotsitself — 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 —
HealthBrutalTortureTestandHealthStressTestSpawner— wereMonoBehaviourscene harnesses rather than NUnit fixtures, and they sat loose inTests/EditMode/Health/above theHostile/andInternalTruth/folders that carry the assembly definitions. Every test assembly is gated on its system'sREV_*_PRESENTdefine, 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 reportedCS0234andCS0246errors 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— movesTeaching/out of the project, which clearsREV_TEACHABLESand stopsRevFramework.Teaching.UIcompiling. 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.csSamples/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_TEACHABLESclears, and that symbol is derived fromTeaching/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 tenCS0234/CS0246errors 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.EconomyDemoOverlaysits underIntegrations/rather thanSamples/and had no assembly definition of its own, so it compiled into the shippingRevFramework.Economy.InventoryIntegrationassembly. DeletingSamples/— 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 —
CurrencyJsonSavedid not save currencies it was not configured with, and a load therefore left them untouched. The component saved the ids in itsCurrency Idslist, or aCurrencySet, or — when neither was set — a hardcodedgold/gemsguess. 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 —
DotEffectandHotEffect— were guarded on aREV_DOT_HOT_PRESENTdefine 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 ausing. -
Status Effects — removed a dead demo-aura overlay. Four blocks in
StatusUtilityandStatusProviderAggregatorwere guarded on aREV_DEMO_AURASdefine and referenced aRevGaming.RevFramework.Demos.DemoStatusPotencyAuratype 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(theICurrencyServicethat lets an item stack stand in for a balance, built viaCurrencyInventoryFactories.InventoryBacked) boundCountOf,TryAddAllResultandTryRemoveResulton the container object by reflection. The object it probed is whateverIInventoryService.Getreturns — 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 reportedServiceMissingwhile 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,StatusBuffBarandStatusIconVieware all guarded on aTMP_PRESENTdefine that was declared nowhere in the framework, so the define was never set for anyone:CurrencyBarTMPdid not exist in any build, and the other two silently fell back to their UGUITextpaths. The three owning assemblies —RevFramework.Currency,RevFramework.StatusEffectsandRevFramework.Currency.Teaching— now declare aversionDefinesentry oncom.unity.ugui2.0.0 or newer, which is where TextMeshPro has lived since Unity 2023.2 (the standalonecom.unity.textmeshpropackage 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 —
WalletSnapshotcannot be saved withJsonUtility, and every signal said it could. The type is[Serializable], documented for save/load, and its backing field name is kept stable forJsonUtility, with a comment saying so — butWalletSnapshotLineis areadonly structwith 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.Restorevalidates 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 fromTools ▸ 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
RevFrameworkmenu, 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.superfenceswas configured without a mermaidcustom_fencesentry, 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.
CraftingRealAdaptersPanelandCraftingRoutingSpaceCurrencyPanelretry theirOnEnablerouter/policy setup when theCraftingServiceshows 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 fromTickas 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 —
CurrencyJsonSaveno longer writes an entry for every object in the scene. AStableIdidentifies 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 calledSetBalanceon each of those objects, creating a wallet they never had. It now writes only owners that hold one, using the newICurrencyWalletQueryabove.
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. WithTMP_PRESENTcorrectly set, the component's serializedstackTextfield takes its intendedTMP_Texttype instead of the UGUITextfallback. Upgrade note: if you built your own prefab fromStatusIconViewand assignedstackTextto a UGUIText, that assignment will be empty after upgrading — re-assign it to aTextMeshProUGUI. 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 referencesStatusIconViewdirectly, becauseStatusBuffBarbuilds its icon views at runtime. -
Currency —
CurrencyBarTMPnow appears in the public API snapshot forRevFramework.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 theDocumentationfolder — having been copied out of RevFramework with their.metaattached. Unity matches a.unitypackageentry 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'sHealthSystemsource 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 theHealthSystemtype — 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 Bleedonly walked assembly definitions insideTeaching/, 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 outsideTeaching/that uses theTeachingnamespace 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.TimeModeread-only property. Read-only counterpart to the existingSetTimeMode(...), 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.3StatusSnapshotsTimepanel bug. Purely additive: no existing signature, behaviour, or serialized data changed. -
Render smoke test across every teachable panel.
PanelRenderSmokeTestsdiscovers all concreteTeachablePanelBasesubclasses by reflection and asserts each renders without throwing when its dependencies are absent — thePanelDependencyGuardcontract, 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 —TeachablePanelBasedrivesTick()fromOnGUI(), which does not fire under-batchmode -nographics. -
Health —
HealthSystem.DeathAvertedevent. 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.Dieddoes not fire whenDeathAverteddoes, and before-death handlers are deliberately not consulted on this path, so a single-use handler such asExtraLifeTotemHandleris not consumed by a hit the target already survived.Kill()is unconditional and never reports an averted death. Declared onHealthSystemonly, not onIHealthMutator, so existing implementers of that interface are unaffected. A designer-facingonDeathAvertedUnityEvent 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 renumberedUnknownErrorand 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.Restorerolls 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 —
ReflectionResolveUtilityhas been removed. This is the first breaking change in RevFramework's history, so it is worth being explicit: if your project usedRevGaming.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 withServiceMissing. 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
NotFoundorUnknownError. 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.
GiveItemEffectresolved 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.
GiveCurrencyEffecthad the same defect, and additionally searched for currency methods that do not exist on any RevFramework service. It now credits or debits throughICurrencyServicedirectly; a negativeamountdebits rather than being rejected. -
Inventory + Pickups — Pickup effects could not be used as inventory item use-effects. The bridge that lets a
PickupEffector 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.
InventoryCraftingAdapterfalls 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 atCurrent == 0withIsDead == false, noDiedevent, 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.TryAbsorbraisedonShieldDamagedbefore the shield-broken branch, so a throwing hook left the shield at zero capacity with noShieldBrokenevent and the exception escaping into the caller's damage pipeline;DotEffectandHotEffectraised theirs before the death check and stack cleanup in their tick loops, so a throw stopped the effect ticking while leaving its state live;ShieldPoolandHealthCombatStatehad already committedTotalandInCombatbefore the event fired, so state and notification could diverge. All 27 sites acrossCapacityShield,ShieldPool,DotEffect,HotEffect, andHealthCombatStatenow log the exception and continue, matchingHealthSystem.HealEventPostRulewas already isolated —HealRuleHubtry/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.
TryGetLastDamageReportis 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 throughNotifyPostDamage. 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.IdempotencyMismatchrather than answered with the earlier result. Applies toBuy,Sell, andCraft.
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.
StatusEffectControllerraised its removal events before dropping the effect from the active list, so aStatusRemovedhandler that readActiveorHasStatusstill 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, andCleanseByTagnow 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, soRemoveAtcould 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.
StatusEffectControllerraised its lifecycle events — the designer UnityEvents and the C# events alike — without an exception guard. Every removal path tears the effect down withRemove(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.ClearAllwas the worst case: it tears every effect down in its loop and clears the collections afterwards, so one throwing listener stranded all of them.DispelandCleanseByTagadditionally 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-suppliedIStatusAuthoritytwice — contradictingApplyStatusCore, 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.RemoveStatusitself is unchanged: it still checks authority and behaves exactly as before when called directly. -
Status Effects —
ApplyOrRefreshran the caller's factory before checking authority. A denied application still invoked theFunc<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
ApplyOrRefreshfactory propagated to the caller. The method isvoidand 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
ApplyOrRefreshcall. The method ran its own immunity check and then delegated toApplyStatus, which re-ran both gates. Caller-suppliedIStatusAuthorityandIStatusImmunityimplementations 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.ApplyJsoninstead of returningfalse. The method is documented as returningtruewhen the snapshot was parsed and applied andfalseotherwise, but the underlyingJsonUtility.FromJsoncall 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 asfalse. 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.LoadFromFileis guarded by the same change. -
Pickups — a decorator creator that returned null corrupted the effect chain.
PickupEffectFactory.BuildEffectassigned each creator's result straight back into the chain, so a creator that failed by returningnull— rather than by throwing — replaced the accumulated effect with null. With one decorator configured the method returnednulloutright, 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 andBuildEffectreturned 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.versionis documented as being "for forward-compat" and is stamped bySaveActiveJobs, 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.RestoreJobsalso gained the XML docs it never had, covering all four skip conditions. -
Inventory — snapshots declaring an unsupported schema version were applied blind.
GameInventorySnapshotDTO.versionwas 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 returnfalse. 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.
CraftingServiceraised 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 withTryStartQueuedJobs, 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.
SceneCurrencyServiceraisedOnWalletChangedand its designer UnityEvent, andAuditedCurrencyServiceraised its audit-entry event, without an exception guard.Transferraises 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.
ShieldSystemraisedonShieldDepleted/ShieldDepletedafter zeroing the shield, andInteractablePickupBaseraisedonPickupFailed/PickupFailedon 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.UnityIntegrationhas 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.asmdefgained"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 makesmodules_dependencies.mdaccurate, which already documented Economy as unsupported without Currency.
Compatibility¶
- Unity 6.5 (6000.5) — silenced the
FindObjectsSortModeobsoletion warnings. Unity 6000.5 deprecatedFindObjectsSortModeand theFindObjectsByTypeoverloads that take it, directing callers to the parameterlessFindObjectsByType<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 CS0618with 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 originalFindObjectsInactivebehaviour. 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
defineConstraintsentry for the system they integrate with, but not for the system they belong to —StatusEffects.HealthIntegrationandStatusEffects.PickupsIntegrationwere missingREV_STATUS_PRESENT, andPickups.HealthIntegrationwas missingREV_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 newRevGaming.RevFramework.Core.RevFrameworkVersionconstant, which also gives you a supported way to query the installed version from your own code:RevFrameworkVersion.Currentreturns"1.0.3". It lives inRevFramework.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
OnEnableguarded by an early return on an unresolved reference, then re-resolved that reference later (inTick, or via the draw-time dependency guard) without retrying the binding. If the target did not exist yet atOnEnable— 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-Buffs —
HealthSystem.SetShieldwas 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-Time —
StatusEffectController.SetTimeModewas 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_snapshottedflag,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 implicitSceneHandle→intconversion (scene.handle) to compile errors. These are replaced throughout with version-portable equivalents: scene caches key on theScenestruct directly, and transient runtime identity keys and comparers useRuntimeHelpers.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 whoseRemovere-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
SpillOverhealToTempShieldenabled 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 withInvalidArgsbefore any mutation. Affected the base store, so every composed stack inherited it. - Currency / Escrow — minting on a clamped hold. A hard-hold
TryHoldwhose debit was clamped by a Clamp-mode policy floor (escrow sits above caps) stored the requested amount, soRelease/ExpireStalerefunded more than was actually taken (minting) andCommitunder-charged.TryHoldnow refunds the clamped partial debit and fails withBelowMinimumrather than placing a short hold, so a hold always reserves exactly what was debited. - Currency / Transactions — rollback over-correction under capped balances.
CurrencyTxn,CurrencyHoldTxn, andCurrencyPurchase(TrySpend/TryGrant) rolled back failed operations by the requested amount. With aClamp-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,TryExchangedebited 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
RequireEscrowguard forwarded an operation, audit entries recorded below it lost theirreason/sourceId. The guard is now transparent on the audit-aware surface and metadata is preserved. -
Currency / Save — non-atomic save write.
CurrencyJsonSavewrote 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.Grantcredited 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/maxSrcare now hard bounds (behaviour change). Amounts belowminSrcfail withBelowMinimum; amounts abovemaxSrcfail withAboveMaximum;TryQuotereturnsfalsefor out-of-range amounts instead of silently clamping. To exchange "up to the cap", requestmaxSrcdirectly. No public API signatures changed.
Internal¶
- Documented the Crafting station-cap convention: a per-station
maxParallelof0(or less) means no per-station limit (unlimited, gated only by the global cap), not "block the station" — matching the globalmaxParallelJobsconvention. 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 theREV_CURRENCY_PRESENTguard the rest of the framework uses, so deletingRuntime/Systems/Currencyfrom the all-in-one package left dangling references (CS0234 / CS0246) in Economy. Those files are now guarded, and theCurrency ↔ Inventoryintegration assembly plus the Economy test assemblies — which were missing theREV_CURRENCY_PRESENTdefine 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.