PeakSafeOptimizer
Performance mod focused on 8-player lobbies. 35 of 41 patches on by default, each equivalence-proven or a vanilla crash fix, per-patch fail-closed validation, opt-in switches for the ones that trade fidelity for frames.
By khalil
| Date uploaded | 3 weeks ago |
| Version | 1.4.0 |
| Download link | khalil-PeakSafeOptimizer-1.4.0.zip |
| Downloads | 3745 |
| Dependency string | khalil-PeakSafeOptimizer-1.4.0 |
This mod requires the following mods to function
BepInEx-BepInExPack_PEAK
BepInEx pack for PEAK. Preconfigured and ready to use.
Preferred version: 5.4.75301README
PeakSafeOptimizer
A performance mod for PEAK aimed at the case that actually hurts: a full lobby standing in one place.
Most of what it does is invisible. 35 of its 41 patches are equivalence-proven - they produce bit-for-bit the same result as the vanilla code, just with less work - or they repair a vanilla fault that throws an exception every frame. Anything that trades appearance, feel or animation fidelity for frames is off by default, because that trade is yours to make, not the mod's.
That default-on rule has exactly two declared exceptions, both on by default and both explained below: Experimental.ExplosionScale (fewer and, in a pile-up, smaller explosion orbs, and a correspondingly softer camera shake) and Experimental.TumbleWeedTargetScan (a tumbleweed re-picks which character it chases once every 5 physics ticks, 0.1s, instead of every tick; the force and the steering towards the character it already chose are unchanged, and tumbleweeds only exist in the desert / mesa biome).
There are no numbers to tune. Every threshold is a constant in the code, next to the reasoning that picked it.
Installation
Mod manager (recommended): click Install. BepInEx is pulled in automatically.
Manual: install BepInExPack PEAK first, then drop PeakSafeOptimizer.dll into PEAK/BepInEx/plugins/.
Works in multiplayer with vanilla players and with players who do not have the mod. Nothing it does is networked - every patch acts only on your own client's workload.
What it actually does
Removes per-frame exception floods. PEAK throws a few NullReferenceExceptions every single frame in situations that are easy to hit (rope climbing with an empty segment list, an incomplete local character while the emote wheel is up, hand IK during an item swap, pocket items during the join window, two unguarded singletons while binoculars are raised). An exception per frame is not free. Eight guards cut those to zero - each one skips exactly the frame whose data is missing and defers to the untouched original otherwise, and each one mirrors the short circuits in the original so it never skips a frame vanilla would have survived. Those eight are counted by what they repair, not by where they live in the source: they are eight independent switches, and one of them - the pocket-item guard - sits outside the mod's stability folder, so counting files in one directory will not reproduce the number. Measured in one session: 2315 + 1544 + 184 + 135 + 67 exceptions. Those counts predate the current guards and the emote-wheel figure in particular has not been re-measured against them, so treat them as the order of magnitude rather than the exact win. The binocular one alone was 1544 - one per frame for as long as anyone held the binoculars up, each costing a throw, a stack capture and a synchronous log write on the main thread.
Cuts redundant native calls. A large part of the per-frame cost in a multiplayer lobby is the game writing values that are already what it is about to write, or recomputing something it computed a moment ago. Examples: Bodypart.Drag multiplies two velocity vectors by exactly 1f for every bodypart of every ragdolled character, 50 times a second; Bodypart.AddMovementForce adds the zero vector for everyone who is not pressing a movement key; the LightVolume lightmap is sampled per character per frame for voice occlusion when clustered players share the same voxel. These are skipped only when the skip is provably an identity.
Replaces scans and lookups with cheaper equivalents. Squared distances instead of square roots, string Animator parameters hashed once instead of per frame, a dictionary instead of a linear search, one full player scan instead of two in the same frame.
Reduces garbage. CharacterMovement.OnCollision evaluated the allocating Collision.contacts property six times per contact; it now reads contact zero once. Optionally (see below) the engine can be told to stop allocating a Collision object plus a ContactPoint[] for every reported collision at all.
Scales down explosions. Vanilla spawns 10 orbs per dynamite explosion, each an animated transparent sphere roughly twelve metres across. This caps a single explosion at 5 orbs at its authored size, and when explosions pile up in the same place it drops the count to 2 and shrinks the orbs to 60%. With ten sticks detonating inside one metre the frame is spent filling the screen several times over rather than on spawning orbs, so the count reduction alone barely moved it - the shrink is there for the fill, though how the cost splits between the orb mesh and the two particle systems each orb also carries has not been measured. A clustered explosion therefore looks smaller per orb, but you are seeing the union of many of them, so the fireball stays wider than any single blast. A lone explosion is not shrunk at all. Two things follow that are worth knowing: fewer orbs also means a softer camera shake, because each orb fires two screen shakes and the game stacks them; and this only covers dynamite - the rocket, rocket-pack and ghost-ball explosions are different prefabs whose cost is in particle systems, and they are untouched. Damage, knockback and blast radius are unaffected either way - those live in a separate component.
Cheapens the swamp spore volumes. Every poison-gas volume in the level runs its proximity check every frame with no distance gate of its own - two to four square roots and two to four ragdoll dictionary lookups each, plus a string-keyed dictionary hash while you stand in one. The replacement resolves your position and its distance once per volume and reuses both, and replaces the overlap-priority lookup with a single dictionary probe. The distance is still a real square root, because a squared comparison is not exact at the boundary and the boundary is a damage tick. The damage itself is reproduced line for line. Note this is CPU only: the clouds themselves are authored particle systems, and whether they are covered by the game's own particle culler is a per-prefab fact this mod cannot see.
Optional switches
All off by default. Turn them on if you want the frames more than the fidelity.
| Switch | What you gain | What it costs |
|---|---|---|
Experimental.RemoteClusterAnimationThrottle |
Remote players evaluate their animation graph every other physics tick instead of every tick - roughly halves the single most expensive managed per-character cost in a cluster | Remote animation steps at 25Hz. Speed is unchanged (the accumulated delta time is passed through), but the stepping is noticeable up close |
Experimental.DetailBodypartThrottle |
Fingers, jiggle bones, toes and jaw of remote players animate at ~16Hz | Those parts lag slightly. Spine, limbs and held items are untouched |
Experimental.RemoteRagdollLod |
Remote players past 60m stop evaluating animation entirely and become passive ragdolls carried by physics and the network | A distant player's pose freezes. Distance is measured from whoever you are watching, so spectating does not freeze the lobby around you. One switch covers four patches that are only coherent together, and they are installed as a unit |
Experimental.CollisionCallbackReuse |
The engine stops allocating a Collision object and a ContactPoint[] for every reported collision - the largest single source of collision garbage in a cluster |
A process-wide engine flag. The game side is fully audited (exactly one place keeps a collision across frames, and it is guarded), but the game also hands the live collision to mods through two public delegates, so another mod that holds one past its callback would start reading a reused instance |
Graphics.CapBoneWeights |
Two bones per vertex instead of four or unlimited - a per-vertex cost of every character on screen, and the game offers no setting for it | Joint deformation at shoulders and hips is a little coarser |
Every switch hot-reloads: change it in a config manager and it installs or uninstalls immediately, no restart. A switch that covers several patches is installed all-or-nothing - if one member cannot install on your build, the others are withdrawn and the log says which and why. The two engine-level lanes restore what they found when switched off, on any patch toggle, and on game exit, and only while the value they wrote is still the live one, so another mod that changed it afterwards keeps its own value.
What it deliberately does not do
It does not override your graphics settings. Shadow distance, render scale, texture quality, LOD bias and anti-aliasing all belong to the in-game menu. An earlier version capped shadow distance and cascade count; that was removed, because it silently overrode a choice the player had already made - and raising the setting appeared to do nothing.
It contains no Transpiler. Every patch is a Prefix or Postfix with an exact structure check. IL rewriting is fragile in a heavily modded environment, and the one Transpiler that survived testing was dropped before release rather than shipped.
It does not touch networking. No Photon serialization rate, no RPC, no interpolation of remote positions. Two patches whose target is an RPC entry point are held to the strictest validation tier.
It does not fix other mods. Faults caused by other mods are out of scope.
Safety model
Each patch validates the game build itself before installing: declaring type, member type, exact signature, and for the enum-driven ones the member order. If anything does not match, that patch reports SkippedStructureMismatch and stays uninstalled. The others are unaffected.
Patches that replace audited behaviour additionally require the game assembly to match the audited build. After a PEAK update, those skip themselves safely rather than running against code they were not reasoned about. The startup log names each one. Nothing is ever installed on a "probably fine" basis.
A failure at any stage - factory, initialization, target resolution, Harmony patching - is isolated to that one patch and logged, or to that one switch when the switch covers several patches that are only correct together. Uninstall is per method and verified against Harmony's own patch info; UnpatchSelf is never called, so other mods' patches on the same methods are never disturbed.
Verifying it is working
Filter BepInEx/LogOutput.log for PeakSafeOptimizer. You should see:
PeakSafeOptimizer | compatibility mode: certified
PeakSafeOptimizer | totals: registered=41, enabled=35, applied=35, skipped=6, failed=0, module-failures=0
applied should equal enabled. The six skipped lines are the default-off switches. failed=0 and module-failures=0 mean no patch hit a problem.
compatibility mode: structural-validation instead of certified means the game has been updated: structural guards still validate independently and run, behaviour-replacement patches skip themselves.
If something goes wrong
Turn off the last switch you enabled. If you enabled nothing, the likely candidates in order are Experimental.CollisionCallbackReuse (if on), then any of the throttles (if on), then the two on-by-default non-equivalent switches, Experimental.ExplosionScale and Experimental.TumbleWeedTargetScan. Every switch is independent.
Diagnostics.DetailedLogging is on by default. It logs a line the first time each repair reason is hit and then at most once every 30 seconds per reason - it never spams per frame, and it only counts repairs and fallbacks, never the normal optimized path. Those lines are the fastest way to tell which guard is doing something.
A crash, a save/network/scene fault, or anything visually broken is worth reporting with the full log.
Compatibility
Built against PEAK with BepInEx 5.4.23.3 and tested alongside 20+ other mods. It patches only vanilla methods, so it does not conflict with content mods. Where the game itself writes a property every frame, this mod deliberately uses a different channel so the two cannot fight each other.
MIT licensed.
CHANGELOG
Changelog
1.4.1
The game shipped another update (Assembly-CSharp MVID c88cc526-… -> e053d265-…). Only
Assembly-CSharp.dll changed - every other game assembly is byte-identical - and no method this mod
patches was rewritten. So this release contains no adaptation work. What it contains is three
long-standing accounting errors, one of which was a real regression.
IKItemGuard reproduced the wrong hand (real regression, fixed)
This patch exists because CharacterAnimations.ConfigureIK raises a NullReferenceException every
physics frame while you hold an item whose prefab lacks a Hand_L or Hand_R child. The two cases
are not symmetric: whichever hand the method evaluates first throws with no writes landed, and the
other hand has already been written twice by the time it throws, so the patch has to reproduce those
writes before skipping. Getting that assignment wrong freezes one IK hand target or performs writes
vanilla never reaches.
It was wrong. The patch was written against the 1.20 body, which evaluated the right hand first;
from c88cc526 onward the game evaluates the left hand first. The bookkeeping was never flipped
with it, so for two releases a missing Hand_L froze the left IK target and a missing Hand_R
performed two right-hand writes the original does not reach. Verified at the IL level
(GetItemPosLeft at IL_0063, the rotation setter at IL_009f, SetPositionAndRotation at IL_00a5,
then GetItemPosRight at IL_00da) and fixed: the patch now reproduces the left-hand pair and treats
a missing Hand_L as the zero-write case. The two repair reasons swapped names to match.
Two patches removed
PocketBehaviorGuard. It claimed vanilla dereferencedcharacter.player.itemSlotsunguarded. The game addedcharacter?.player == nullplus anitemSlotsnull/length check inc88cc526- the release this was last audited against - so the guard has been redundant since 1.4.0. Because its prefix returned true and let the original run, it was pure per-character per-frame overhead. This is the same class of mistake as the three patches 1.4.0 removed; this one was missed.AnimatorValuesHash. A census of every asset file in the shipped game (all.assets, everylevel*, everysharedassets*,globalgamemanagers, 810 MonoBehaviour classes with instances) finds zeroAnimatorValuesinstances - against 23UIPlayerNames, 1Snowballand 3ExplosionEffect, all of which the same sweep resolves. Nothing in the game or in any installed mod adds the component in code either. Its target method can never run, which makes it the same situation as theParticleCountCullerWritepatch deleted in 1.2.0.
Counts: 39 patches / 35 switches, 33 on by default, down from 41 / 37 / 35.
Three comments whose conclusions had gone stale
No behaviour change, but they were load-bearing arguments and are now correct:
ExplosionScalejustified leaving nine prefab fields unasserted partly on the grounds thatrequireCurrentMvidwould skip the whole patch on an unrecognised build. 1.4.0 deliberately removed that gate, so the backstop it leaned on no longer exists. The comment now says plainly that those nine fields are protected only by re-auditing a fresh decompile after a game update.SleepingZombieScancalled its squared-distance replacement "provably equivalent". It is not bit-exact:t*trounds once in float and the comparison moves onto a different quantum, leaving a band at most one ULP wide where the two disagree (roughly half of plausible thresholds have such a band, and the patch errs slightly permissive). The probability of a real zombie landing in it is around 1e-10 per test, so the behaviour is fine, but the comment now says "within one ULP" instead of "identical".RemoteRagdollLodAnimatordescribed a vanillaDebug.LogWarningabout swapped animator controllers. The current build deleted that log line and kept the check, so the comment now describes the check without the log.
Documentation
README.md and TESTING.md are gone. Their per-patch tables and test checklists duplicated what the
patch docstrings already state, and every code change needed them rewritten, which is a worse use of
attention than the code itself. The source docstrings are the specification. This changelog and the
Thunderstore README cover only what a user needs.
1.4.0
The game shipped a low-level update: it now does a batch of its own performance work, plus a console port. Assembly-CSharp's MVID moved, and three of this mod's patches were made redundant by the game itself. 41 patches and 37 switches, 35 on by default, down from 44 / 40 / 38.
The MVID gate is gone, and that is the headline
Until 1.3.0 the installer had this, ahead of every patch's own validation:
if (definition.RequiresCertifiedBuild && !fingerprint.IsCurrent) return;
43 of the 44 patch modules set that flag. So one game update silently skipped almost the entire mod - before a single patch got to check whether the members it actually reads had changed shape. That is the opposite of what the per-patch structural validation exists for, and it turned every game update into a release event. Removed.
The only gate now is each patch's own Initialize assertions plus its target signature check: shapes
match and it installs, shapes differ and that one patch reports SkippedStructureMismatch. The MVID
is demoted to a log label and CompatibilityLevel is demoted to a re-audit priority.
SkippedUncertifiedBuild is no longer produced by anything.
The cost, stated plainly: structural validation proves that member shapes are unchanged, not that
method bodies still mean the same thing. Only re-reading a fresh decompile catches the second kind, so
structural-validation in the log should be read as "nobody has re-read this build yet - start with
the Fingerprint and StrictIl patches".
Three patches removed, because the game now does their job
- GenericOptimizerRange.
AnyCharacterWithinRangenow squares the range, indexes the list and hoiststransform.positionout of the loop - exactly the three things this patch did. Worse, the game gaveGenericOptimizera private cached transform property, which this assembly cannot bind to, so C# member lookup falls through toComponent.transform: the patch became one extra native call per instance per frame in exchange for nothing. There are 2133 instances. - StatusEmitterScan.
StatusEmitternow caches its transform and computes the distance once;Update()does not even callInRange()any more. Counted call for call, the replacement makes the same number of native calls and the same one square root as vanilla, and adds a Harmony wrapper frame, three delegate null checks, a try/catch region and two FieldRef invocations. Net negative. - RagdollPhysicsMats.
CharacterRagdoll.FixedUpdatenow guardsSetPhysicsMats()withif (character.IsLocal), so this redundancy filter can only save one character's worth of native material writes instead of every character's. It was the most complex patch in its lane (a ConditionalWeakTable, a two-phase pending/promote signature and a 300-frame / 5-second material audit); that trade no longer holds.
Adapted to the new build
Four real behavioural deviations and one logic gap:
- RemoteClusterAnimationThrottle called
SetPhysicsMatson every tick, evaluating or not. Vanilla no longer calls it for remote characters, and remote characters are the only ones this patch touches, so continuing to call it would be adding per-step nativecol.sharedMaterialwrites the game no longer performs. Both call sites, the accessor, the delegate and theset-physics-mats-faileddiagnostic are gone. - IsLookedAtScan.
UIPlayerNames.Initchanged from returning anintto taking anAction<int>callback - the one assertion in the whole plugin that would now fail, which after the gate removal means the patch would be skipped rather than silently wrong.Update()also hoists the blind/struggling test to the top and returns early, so the replacement follows, and the camera null check moved below that gate: otherwise a null camera while blind would hand the method back, where vanilla never touches the camera and cannot throw. - BarAfflictionLayout. The game added
size == 0f -> width = 0fand anApproximatelysnap. The old predicate only forecast theLerp, so it would skip a write that should have snappedwidthtosize. Rewritten branch for branch, and the float equality is now a bit comparison, because-0f == +0fwould drop a real write. - GenericOptimizerRange (confirmed on the way out): the patch's
candidate == null -> continuewas itself a deviation. Both the old and the new vanilla loop dereferencecharacter.dataunguarded, so a null element throws in both. - RemoteRagdollLod gained
data.carrierCondor != nullin its never-downgrade list. That field is new: a character being carried by a condor keepscurrentRagdollControllat 1, so it passes the control threshold, while the condor takes it far past 60m - and none of the three existing carry conditions could see it. Such a character would have gone limp mid-flight.
Verified as harmless: Bodypart's SetPositionAndRotation merges and the dropped isKinematic guard
in FollowRotation_Rotation (that guard was dead code - its only caller returns early when kinematic);
CharacterMovement.FixedUpdate hoisting four currentRagdollControll multiplications out of the loop;
the new AdjustDrag (AdjustDrag(1f) == 1f exactly, and its preimage is unique, so
BodypartDragIdentity's identity test is unaffected); ConfigureIK evaluating the left hand first now
(so IKItemGuard's two per-side reasons swap roles); the ItemPhysicsSyncer.FixedUpdate rewrite; and
the squared-distance rewrites in TumbleWeed.GetTarget and GenericOptimizer.
Every remaining patch's Initialize assertions were checked against the shipped DLL with
System.Reflection.Metadata rather than against decompiled text. Apart from that one Init signature,
all of them still pass.
Comment convention change
There were roughly 450 File.cs:NNN references in the comments. A game update invalidates all of them
at once, and re-anchoring them had grown more expensive than the code fixes. New convention: name the
type and the method, not the line. Existing line references are left as historical coordinates rather
than re-anchored one by one.
None of the above is verified in game. See TESTING.md; the 1.4.0 acceptance points are at the top of its final section.
1.3.0
Still 44 patches and 40 switches, 38 on by default. Seven concurrent read-only audits over the whole plugin, then a repair pass over everything they turned up that could be settled by reading code rather than by playing. One behaviour regression, three real defects in the group installer, one threshold that was a magic number and is now derived from the assets, and a long list of comments that were confidently wrong.
The regression, first
IKItemGuard froze the right IK hand target on any held item whose prefab has a Hand_R child but
no Hand_L. C# evaluates a.position = f() receiver-first, so on such an item vanilla completes
both right-hand writes (CharacterAnimations.cs:397-398) and only then raises at :399. The guard
skipped the whole item branch, so the right hand stopped tracking the item - every physics frame, for
as long as it was held. It now reproduces those two writes through delegates bound to the two
internal helpers before skipping, and books the two cases separately
(right-hand-node-missing vs left-hand-node-missing). This is the same class of mistake as the
1.1.0 emote-wheel regression: skipping the writes vanilla performs before it throws.
Group installs were not all-or-nothing
Three defects, all in the machinery that makes one switch install several patches as a unit.
- HarmonyX registers a prefix into the patch info before generating the wrapper
(
PatchProcessor.Patch), so when wrapper generation throws,GetPatchInfoalready lists our method. The installer recorded that as an installed patch,IsInstalledreturned true for a patch that had failed, and the group rollback was skipped entirely - leaving a half-installed switch with nothing in the log to say so. A record in that state is now flagged separately: never live, never reinstalled (that would attach the prefix twice), still cleaned up on uninstall. - Rolling a group back claimed members had been withdrawn cleanly when their
Resetthrew. It asked a question that excludes exactly that case, took the success branch, and discarded the real reason. It now reports which of the two failure shapes happened. - A member that was already live before a failed install kept running, unreported. It is now named as a deliberately half-installed switch.
Also: the failed-startup rollback shut the engine-global lanes down after detaching the patches,
the opposite order from a normal shutdown, leaving a window where Physics.reuseCollisionCallbacks
was still set with the Snowball guards already gone.
The cluster radius is no longer a magic number
ExplosionScale used a fixed 3.5m to decide whether an explosion overlaps a recent one. That is
smaller than the main dynamite's own fireball radius, so two detonations 5m apart - visually almost
fully overlapped, which is precisely the fill this patch exists to cut - counted as isolated and each
kept five full-size orbs. The threshold is now derived from the orb geometry the assets carry: the orb
root's localScale (4.4401164) times the Icosphere mesh AABB extent (1.372998), i.e.
baseScale * 6.0962 - about 6.10m for the main dynamite and 3.05m for the Small/Fae variants. A NaN,
zero, negative or infinite baseScale falls back to the old constant. The test uses the current
explosion's radius, because what is being decided is whether this fireball lands on screen that is
already covered.
Two more things in that patch:
- The scene-handle debounce is gone. It guarded against last scene's positions being counted as
neighbours, but the 2.5s window already does that: every scene load in this game is
LoadSceneMode.Singlebehind a loading screen that also waits for the local character to spawn, and segment changes areSetActivewithin one scene. Its only observable behaviour was a falsescene-handle-thrashreport, so that reason no longer exists. - The light tier's caps were
Math.Min(pointCap, 2)andMath.Min(subPointCap, 1)against a 2/1 base - identity - while the comment claimed it cut counts from one neighbour on. Those caps are now named constants of their own, so the no-op is explicit and giving that tier a real reduction is a one-constant edit.
Diagnostics that were measuring the wrong thing
The repair counters are cited as evidence of vanilla faults, so a counter that fires when nothing is wrong is worse than no counter.
WeightRefreshCoalescingcounted a repair reason on the path where the patch succeeds.same-frame-duplicate-skippedis removed.PocketBehaviorGuardcounted one per bot per frame.Character.playeris null for bots by design, so skipping the call is right and the accounting was not - the same exclusionParachutealready had.AnimatedMouthMaterialtested the renderer and the texture array before the audio source, but vanilla's guard short-circuits on the audio source and runs no body at all, so a mouth with no audio source was reported as a fault every frame.RemoteClusterAnimationThrottlesilently swallowed a null rig builder and an invalid animation graph and reported success. Vanilla raises there every tick. The guards stay - reintroducing a per-step exception would be worse - but a malformed rig is now visible asrig-builder-unavailable/animator-graph-invalid.
Hot paths
LightVolumeSampleCacheallocated a carrier object on every cache miss, the last per-call allocation in the plugin. The Postfix now recomputes the voxel key from the same argument through the same pure function, so nothing is carried across the call.RemoteRagdollLodread a static property behind a native alive check before its per-frame decision cache - useless on the cache-hit path, which is where the roughly twenty thousand calls a second land.AnimatedMouthMaterialread two material properties by string name every frame; the ids are resolved once at install.RemoteItemInterpolationThresholdpaid three native calls before four managed checks that could reject the frame. Single-player and non-synced items now cost none of them.DetailBodypartThrottlecomputed the physics tick twice per detail part per step, and never checkedTime.fixedDeltaTime: a zero step would freeze the tick and permanently skip some parts, the one direction in which a stuck tick is not safe.PlayerNameUiWritesresolved the same references two to four times each.ItemScaleRedundantWritedid three redundant destroyed-object checks per call.
Arguments and assertions that were wrong
StatusEmitterScancallsScreenVFX.StartFX(), whose0.5fdefault is compiled into this DLL. Nothing asserted the overlay chain at all; all four members are asserted now, plus that the default is still0.5f- which no signature check can see.RopeSegmentGuardjustified skipping a frame with "idempotent copy, recomputed next frame". That value is written once per grab and is constant for the climb, so the copy only ever matters on the first successful frame. The real argument is that the rotation it feeds is restored within the same physics step.BoneWeightCapclaimed URP rewritesantiAliasingevery frame; that write is in the pipeline constructor. Nothing in the game or URP writesskinWeightsand nothing changes quality level, so the periodic reassert is kept only as insurance against writers that cannot be seen from here, and now says so. Its restore also cleared its bookkeeping after the write-back rather than in afinally, so a throwing restore left a stale snapshot that the next disable would write over the player's newer value.RemoteRagdollLodAnimatorlisted an accepted cosmetic cost - a stale water flag - that does not exist, because the replacement does write the parameter that flag reads.RemoteRagdollLodPhysicsrejected re-bounding the friction-audit window on the grounds that it could not be shown whether a delegate call is seen by a Harmony detour. MonoMod's own startup selftest does exactly that and asserts the hook answered, so BepInEx could not start if it were not. The real reason is stronger:Collider.sharedMaterialhas exactly four writers in the whole game and all four are inside the method being skipped, so in vanilla there is nothing for the missed audits to correct.RemoteRagdollLodclaimed the cluster lane validates the fixed timestep for both lanes. It validates it for itself.Acquire()sat before two throwing statements while the comment asserted nothing below could throw.
Subtraction
DetailBodypartThrottle kept its own copy of the eligibility list and now calls the shared one.
RemoteRagdollLodAnimator emitted two dynamic methods to reach Animator members that are public in
an assembly this project already references. Nine assertions on fields ExplosionScale neither reads
nor writes are dropped - a shape check cannot protect an argument that depends on serialized
values, which is what those arguments depend on. Plus: two config/report methods whose docstrings
claimed hot reload used them (it does not), an unused module-cleanup channel, three unused emit
helpers, and twelve unused using directives.
Documentation
The README claimed 13 orbs per explosion in one paragraph and 10 in another, and that the patch only
cuts counts (it cuts counts and shrinks them). Collision.contacts is evaluated six times, not seven
or four - four places said three different numbers. TumbleWeedTargetScan is now a declared
exception to the default-on rule with its cost written out. Five section subtotals in the test guide
disagreed with their own tables, the results template omitted five items, and the README's
per-section directory labels were wrong three times out of five, because the sections are organised by
the kind of optimisation and the directories are not.
None of the above is verified in game. See TESTING.md; its acceptance order is unchanged.
1.2.0
44 patches and 40 switches, down one from 1.1.0: ParticleCountCullerWrite was removed. A third
audit pass, this time with the shipped game assets decoded rather than only the decompiled code,
which corrected several claims this mod had been making since 1.0.0 - including the one that made
that patch pointless. One of the fixes is a behaviour regression 1.1.0 introduced; the rest are
correctness, blast-radius and honesty repairs.
Removed
ParticleCountCullerWrite. Decoding every.assetsfile and every level shows the component it patches,ParticleCountCuller, has zero instances in the shipped game - the class is compiled into the assembly but nothing carries it. Its target method is therefore never called, so the patch could only ever cost a detour. It followed the rule the reserved config keys andJointProjectionwere dropped under: a patch whose win is provably zero does not ship.
The regression, first
EmoteWheelGuardcould leave the emote wheel stuck open. Vanilla short-circuits in two places -canEmotereturns early for a dead character without readingrefs.stats, andcanEmote && input.emoteIsPressednever touchesinputwhencanEmoteis false - so on those frames vanilla does not throw at all; it falls through and closes an open wheel. The guard was skipping the whole method there, suppressing that close and latchingusingEmoteWheel, which also latches the pause-menu gate, the cursor lock and a movement gate. The guard now mirrors both short circuits and only skips a frame where vanilla would genuinely dereference a null. Introduced by theinputcheck added in 1.1.0.
What the assets said
- An explosion was never 13 orbs. The
4 / 2defaults inExplosionEffectare overridden by the serialized values on the three prefabs that actually carry the component: the main dynamite is3 / 2, i.e. 10 orbs, and the small and fae variants are3 / 0, i.e. 4. The old cap of3 / 1therefore did nothing at all to the first number. The cap is now2 / 1: 5 orbs on the main dynamite, 3 on the variants. ExplosionScaledoes not cover every explosion. Reverse-resolving everyexplosionPrefabreference in the asset library finds exactly threeExplosionEffectinstances, all dynamite. The rocket, rocket-pack and ghost-ball explosions instantiate prefabs whose cost is in particle systems and which carry noExplosionEffectat all, so they are untouched. Said plainly in the README now instead of being listed as covered.- Fewer orbs also means a softer camera shake. Every orb prefab carries two self-firing
AddScreenshakechildren, and the game stacks those rather than replacing them, so orb count and shake strength move together. That is a second visual cost this mod was not declaring. It is declared now; the switch stays on by default, because the explosion case is the one where the default-off rule would leave the worst stutter in the mod unaddressed. - The claim that shrinking the orbs is where most of the win comes from is withdrawn, and this one is now settled rather than merely unproven: both particle systems inside an orb are authored with a local scaling mode, so the root scale the shrink writes never reaches them. Their particle sizes, burst counts and lifetimes are unaffected. The shrink only reduces the orb's sphere mesh, so the count reduction is what cuts the particle work, the mesh work and the screen shake together.
Two more asset corrections, no code change
- The spore volumes really do run unthrottled: 1571
StatusEmitterplus 108WindAffectedStatusEmitterinstances exist and nothing distance-gates them. The game's own script disabler,GenericOptimizer, is only on arrow shooters and venus fly traps. The spore clouds do carry aParticleCulleron their particle child, but it only starts and stops the particles - it cannot touch the damage volume, and the manager that drives it visits three cullers per frame. GenericOptimizerRangeis worth more than its own comment claimed, not less. All 2133GenericOptimizerinstances override the range with a finite value (82m or 60m); thefloat.PositiveInfinityin the class is only the field default, and serialized values win.
Declared, not changed
TumbleWeedTargetScanis now written down as the second declared exception to the default-on rule, alongsideExplosionScale. It has shipped on by default since 1.0.0 while not being behaviourally equivalent, and that was never said out loud. What it gives up, exactly: the target solve runs once every 5 physics ticks (0.1s) instead of every tick, so a newly appearing closer or better-angled character is adopted up to 0.1s late. The force is still applied every tick, and its direction is recomputed from the cached target's current centre, so the lag only affects which character is chased, never the tracking of the one already chosen; a target that died or was destroyed forces an immediate rescan. Exposure is narrow - tumbleweeds only exist in the desert / mesa biome. The switch stays on by default, because that is the map the framerate complaint came from. No code changed.
Correctness
StatusEmitterScanrestored vanilla's ordering in the warning-end branch. It cleared the in-zone flag after calling the screen effect, so an effect that threw left the flag set and the branch re-entered - and re-threw - every following frame, where vanilla's single throw is self-limiting.StatusEmitterScannow reads__runOriginal. It is a full replacement with side effects - it applies spore damage and mutates a static dictionary - and without that parameter another mod's decision to suppress the original was silently ignored.StatusEmitterScanno longer reaches its static overlap dictionary through reflection on the hot path, and its two deferral paths were reworked: an unavailable ragdoll centre is now handled rather than handed back for the original to throw on again, and the afflictions check moved to the point where vanilla actually dereferences it, which is a damage tick rather than every frame.- The distance tier of
RemoteRagdollLodmeasures from whoever you are watching, not from your own character. While spectating or after a warp those are different points, and the old basis could freeze the pose of someone standing right in front of the camera. - A downgraded remote character no longer keeps a stale crouch or sprint height. The two target heights are written only by the method the tier skips, and they feed the standing force and the ground-check ray length, so a pose change now returns the character to full behaviour.
- The warm-up before a first downgrade counts physics steps instead of rendered frames. It exists to guarantee the animation targets have been written at least once, and those are produced per physics step, so at a high frame rate the old count could elapse before a single one had run.
- The physics-step index is derived from
Time.fixedTimeAsDouble. The float it used accumulates from process start and eventually stops being able to separate two adjacent steps. - The phase that staggers throttled characters no longer uses the low bits of the Unity instance id. Objects created in one batch get consecutive ids, so with an interval of two every remote character could land on the same tick - turning the flat cost the stagger exists to spread back into a spike.
- A failing replacement in
RemoteClusterAnimationThrottlenow disables itself for that one character rather than for the whole session, with a process-wide cap for genuinely systemic failures.
Blast radius
- A switch that covers several patches is now installed all-or-nothing. If one member cannot install, the ones that did are withdrawn and reported, because those sets exist precisely because a half-installed combination is a state nothing was reasoned about.
- Turning off the Snowball guard no longer leaves
Physics.reuseCollisionCallbackson for a frame. The flag is lowered before any patch toggle and re-armed after, instead of waiting for the next frame's poll - that window ran vanilla Snowball against exactly the reused instance the flag's safety argument depends on it not seeing. - Both engine-level lanes now restore only their own write, and only while it is still the live value, and they re-sample on every enable. Previously the collision flag was written back unconditionally, which would clobber another mod that had set it, and both lanes could restore a stale snapshot after an off/on cycle.
- The two Snowball patches finally register their
Reset; it had been dead code. - A patch whose
Resetthrew is no longer reported as installed forever. It used to answer "already installed" to every later enable while its Harmony methods were long gone. - A failed startup now shuts the engine-level lanes down even when the Harmony uninstall did not
fully succeed. The component keeps receiving
Updateeither way. BinocularSunGuardalso guards the ragdoll centre the method dereferences past its own guard clause, and it now reproduces vanilla's short-circuit before checking anything behind it, so a non-Mesa or night-time frame no longer books repair reasons vanilla never earned. Those counters are quoted as evidence of vanilla defects, so a false positive costs more than the branch.
Documentation corrections
CharacterMovement.OnCollisionreads the allocatingCollision.contactsproperty six times, not seven:CharacterMovement.cs:928,:948,:953twice and:955twice. The docs and the patch's own comment had claimed seven since 1.0.0. The patch itself is unchanged - it still readsGetContact(0)once - only the count quoted for the vanilla method was wrong.IKItemGuardnow books its repair reason separately for a missing right-hand node and a missing left-hand node, so the log says which side is absent instead of only that one of them is.WeightRefreshCoalescingno longer books a diagnostic reason on its success path. Booking one there broke the project rule that repair counters only ever record repairs and fallbacks, which is what makes a non-zero counter evidence of a defect.
None of the above is verified in game yet. See TESTING.md.
1.1.0
Same 45 patches and 41 switches as 1.0.0, no additions and no removals. This release is a second audit pass that corrects four equivalence defects, widens two guards, and fixes ExplosionScale, whose cluster reduction turned out not to trigger in the case it was written for.
Equivalence repairs
StatusEmitterScan- the replacement returned early from the warning branch, where vanilla falls through into the tail that clears the tick timer. A poison volume you walked out of therefore kept its accumulated tick time instead of resetting it, so re-entering could deal a tick sooner than vanilla. The branch now falls through exactly as the audited method does, and the warning FX is resolved before any field is written so a deferral leaves the emitter untouched.StatusEmitterScan- the range test is back to a real square root. Comparing squared magnitudes is not exact: the two forms disagree inside the rounding band around the threshold, and the in-range side of that band is a live damage tick. The saving that remains is computing the distance once per call instead of the two to four times vanilla computes it, plus oneTryGetValuein place of aContainsKeyand three string-keyed indexer lookups.ParticleCountCullerWrite- the redundant-write filter remembered the last value this patch wrote. That is only equivalent while nothing else writes the same emission module, and the game does write one elsewhere (the bee swarm decrements its own emission multiplier every frame while dispersing). The filter now compares against the live value on the particle system, which stays exact no matter who else writes it.RemoteClusterAnimationThrottle- the explicitRigBuilder.SyncLayers()call was removed on the grounds thatEvaluateperforms it. It does, but only when the builder owns a valid graph of its own, and PEAK builds the rig into the animator's graph instead, which leaves that property unset.Evaluatewas therefore a no-op and the IK constraint data stopped being pushed into the jobs. Both calls are restored, as in the audited method.
Wider guards
BinocularSunGuard- also guardsMainCamera.instanceand the achievement manager singleton, which the same method dereferences unguarded past its own guard clause.EmoteWheelGuard- also guardsCharacter.input, the one remaining unguarded link in the chain the method walks, and reportslocalCharacterseparately from the rest.
Behaviour on failure
RemoteClusterAnimationThrottle- a replacement that fails on the same physics tick it was handed no longer yields that tick to the original, because doing so would runRotateCharactertwice on one tick and leave the rig root rotated. The tick stays skipped; every later tick goes to the original.
ExplosionScale now attacks the right cost
Reported: ten sticks of dynamite detonating inside one metre still ran at about five frames per second. Confirmed as a design error rather than a failure to apply.
- The cluster tier could not trigger in that scenario. It needed two prior explosions within 1.2s, but a single explosion keeps spawning orbs for a full second after it starts, and its neighbours are what the new orbs draw over - so the window was shorter than the overlap it was meant to detect. The window is now 2.5s and the light tier starts at one neighbour instead of two, so every explosion after the first in a pile takes a reduction.
- Reducing the orb count was never going to fix it on its own. An orb is an animated transparent sphere roughly ten metres across, so an observer inside the pile has every surviving orb covering the whole screen; the frame cost is orb count times screen area and only the count was being reduced. The cluster tiers now also shrink the orbs, to 85% and 60% of their authored size, which is where most of the saving comes from - screen area goes with the square of the radius.
- A lone explosion is unaffected: it sees no neighbours, so its size is exactly what the prefab authored. The size field is only ever lowered, feeds no damage path, and is read by exactly one line in the whole assembly.
None of the above is verified in game yet. See TESTING.md, steps A6, A8, A9, B13, E1 and E2.
1.0.0
First public release. 45 patches, 41 switches, 39 patches enabled by default.
Vanilla fault repairs
Eight guards that remove per-frame exception floods. Each skips only the frame whose data is missing and defers to the untouched original otherwise.
RunBasedValues- null run-data dictionaries during serializationRopeSegmentGuard- empty rope segment list while climbing (184 exceptions in one session)IKItemGuard- item prefab missing hand nodes during an equip (135)EmoteWheelGuard- incomplete local character while spectating, respawning or quitting (2315)PocketBehaviorGuard- unlinkedCharacter.playerduring the join window (14)BinocularSunGuard- two unguarded singletons in the astronomy-badge check, one throw per frame while binoculars are raised (1544)ItemOptimizerDeregisterGuard- vanilla passes a null key toDictionary.Removefrom its own disable-on-null path (67)Parachute- conservative cache clear on incomplete character/slot/backpack state
Equivalence-proven optimizations
Same result as vanilla, less work. Squared distances, hashed Animator parameters, dictionary lookups, merged scans, and identity filters that skip a write only when the write provably changes nothing.
Campfire, CampfireProtectionMerge, HeatEmissionScan, RagdollPhysicsMats, ItemCollisionMode, GenericOptimizerRange, ItemDatabaseNameLookup, IsLookedAtScan, ItemAudioManagerHash, BarAfflictionLayout, AnimatedMouthMaterial, CollisionCharacterLookup, AnimatorValuesHash, PlayerNameUiWrites, BodypartDragIdentity, BodypartMovementForceIdentity, LightVolumeSampleCache, CollisionContactsNoAlloc, SnowballContactGuard, ZombieScanRange, ZombieSpawnScanRange, SleepingZombieScan, WeightRefreshCoalescing, ItemScaleRedundantWrite, RemoteItemInterpolationThreshold, EyeLookComponentCache, TumbleWeedTargetScan, StatusEmitterScan, ParticleCountCullerWrite
Visual reduction, on by default
ExplosionScale- 13 orbs per explosion down to 7, with further reduction for clustered and distant explosions. Explosions are visibly smaller; this is the difference between a playable and an unplayable moment when a lobby throws dynamite together.
Opt-in, off by default
RemoteClusterAnimationThrottle- remote animation graph evaluated every other physics tick (25Hz stepping, unchanged speed)DetailBodypartThrottle- remote fingers, jiggle bones, toes and jaw at ~16HzRemoteRagdollLod- remote players past 60m become passive ragdolls (one switch, four patches)CollisionCallbackReuse- engine stops allocating aCollisionplusContactPoint[]per reported collisionGraphics.CapBoneWeights- two bone weights per vertex
Notes on what was removed before release
- The only Transpiler was dropped. IL rewriting proved unreliable alongside IL preprocessors, and its win only existed while someone stood on a bridge.
- A shadow distance and cascade cap was dropped: it silently overrode the in-game Shadow Distance setting, and forcing a single cascade made distant shadow edges shift while walking.
- An MSAA override was dropped: turning anti-aliasing off is a visual decision that belongs to the player.
- A Mesa heat-haze suppressor was dropped: the value it targeted measured 0.016 at runtime, i.e. effectively already off.
- Every numeric config entry was replaced by a switch. A number in a config file cannot be validated by the person editing it, and a wrong one fails quietly.