Find out which mod is making your game lag, and who is bombing — or sneakily feeding — your room.
1.0.3 focuses on quiet-by-default, structured data and targeted diagnostics: profilers are off until you need them, but a one-line config flip turns the mod into a full performance lab with Harmony-conflict scan, method stack tracer, and a JSON-driven field probe.
WhySoLaggy.dll into PEAK/BepInEx/plugins/.PEAK/BepInEx/ and look at:
WhySoLaggy.log — performance data (quiet by default, see Log Verbosity)WhySoLaggy_Abuse.log — abuse alerts + RPC reports + feeding-chain traceswhysolaggy_data.csv — every event in 58 fixed columnswhysolaggy_events.jsonl — every event as JSON (one per line)harmony_patches.csv — every Harmony patch in the process (written once at startup)Zero extra setup needed. All advanced features are opt-in.
Each feature has a default state. Features marked opt-in cost zero when disabled.
SpikeThresholdMs (default 50ms) is logged as a spike and written as a SpikeFrame event.ReportIntervalSeconds (default 10s) emits an FpsReport event with average/p95/p99 frame ms, window average, and (if memory monitor is on) AllocRateKBps.Time.unscaledDeltaTime read per frame. Negligible.EnablePluginProfiling=true, Harmony-patches Update/LateUpdate/FixedUpdate on every BepInEx plugin's MonoBehaviours and records their per-frame cost.IgnorePluginGuids (comma-separated) to permanently mute noisy plugins. Example:IgnorePluginGuids = com.example.ui-mod,com.example.minimap
Update adds measurable self-overhead. Turn on only when diagnosing.EnablePatchProfiling=true, wraps every Harmony-patched target method with prefix/postfix timing.MinReportMs (default 0.1ms), only every 10th call is recorded afterwards. Cuts self-overhead by ~70–90% in busy scenes.IgnorePatchMethods lets you skip known noisy methods:IgnorePatchMethods = Player.Update,Camera.LateUpdate
__originalMethod is logged as "(unknown)" instead of throwing inside Harmony.⚠ ABUSE ALERT is written to WhySoLaggy_Abuse.log together with the offending player's nickname + ActorNumber and the current top-5 RPC method names for context.ActorMethodHotspot — the same actor sent the same RPC ≥ ActorMethodRateThreshold times in one check window (default 20/s). Pinpoints which client is flooding which method, not just the total.OwnershipGrab — one actor pulled ≥ OwnershipGrabRateThreshold PhotonView ownerships in one window (default 10/s). Surfaces silent "I own everything now" griefing patterns.PhotonNetwork.Instantiate is prefix-hooked; first 3 calls per prefab + one sample per 5s window capture a filtered caller stack (TraceCaller, TraceStack) so you can see which mod or script is producing the spawn. An Instantiate flood automatically forces a stack capture on the next call.Default thresholds (per second): Instantiate 15, Destroy 20, RPC 50, ObjectSpike +30, ActorMethodHotspot 20, OwnershipGrab 10.
Hooks PhotonNetwork.ExecuteRpc and tracks every RPC.
_windowByMethod — 1-second rolling count, powers the abuse alert and Top-N list_watchedByMethod[m].Records — 32-entry ring buffer per watched method, survives across windows, holds full detail recordsSendFeedDataRPC → RemoveFeedDataRPC → GetFedItemRPC → Consume preserved in order, so you can see "who fed whom with which item".ConcurrentQueue. All dictionary/PhotonView work happens on the main thread, batched at PumpBatchSize (default 32/frame).[RpcMonitor]
ExtraWatchMethods = MyModRPC_Foo,AnotherModRPC_Bar
Every event — FpsReport, PluginTiming, PatchTiming, SpikeFrame, AbuseAlert, RpcCall, PeriodicReport, HarmonyPatchMap, MethodTrace, FieldProbe, InstantiateTrace, RemoteRpcTrace, OwnershipChange — is also written to:
BepInEx/whysolaggy_data.csv — 58 fixed columns, Excel/Power-BI friendlyBepInEx/whysolaggy_events.jsonl — one JSON object per line, perfect for jq / any AIExample whysolaggy_events.jsonl line:
{"ts":"2026-04-24T15:30:12.451","type":"RpcCall","method":"SendFeedDataRPC","sender":"PlayerB#2","targetPath":"Mushroom Lace(Clone)","payloadBytes":20,"detail":"喂食者=PlayerB#20013, 被喂者=PlayerC#30013"}
Automatic rotation: when a file exceeds MaxLogFileSizeMB (default 10MB) it's renamed with a timestamp suffix (e.g. whysolaggy_data_20260424_1530.csv) and a fresh file is created. Long sessions no longer produce ever-growing logs.
Flushes every 10 events + once per periodic report, so a crash loses at most a few seconds.
Once at startup, the mod snapshots every Harmony-patched method in the process to:
BepInEx/harmony_patches.csv — columns: TargetMethod, PatchType, OwnerHarmonyId, PriorityHarmonyPatchMap events)If the same target method is patched by two or more different Harmony IDs, a warning lands in WhySoLaggy_Abuse.log and on the dashboard. This instantly surfaces silent mod conflicts.
Sample harmony_patches.csv row:
CharacterAfflictions.AddStatus,Prefix,com.wuyachiyu.Lantern,400
CharacterAfflictions.AddStatus,Prefix,com.otherauthor.SomeMod,400 ← CONFLICT
Zero runtime cost after the initial scan.
Attach a stack-capture prefix to arbitrary methods without writing code.
[MethodTracer]
TraceMethodNames = Player.Update,ColdComponent.Apply
TraceMaxDepth = 5
TraceRateLimit = 100
For each matched invocation the tracer records timestamp, the top N frames (Unity / HarmonyLib / MonoMod frames filtered out), the immediate caller, and a short argument summary — written as a MethodTrace event.
Per-method rate limiter prevents high-frequency methods from flooding the log; the first overflow logs a single warning and the rest are silently dropped. Empty TraceMethodNames = hook never installed, zero cost.
The most powerful 1.0.3 addition: reflectively snapshot any field, parameter or return value at any method, controlled entirely by a JSON rules file. Useful when you need to prove or disprove a specific hypothesis (e.g. "is this Cold-status change going through AddStatus or SetStatus?") without recompiling.
Enable:
[FieldProbe]
EnableFieldProbe = true
RulesFile = WhySoLaggy.fieldprobe.json # resolved under BepInEx/config/
DefaultRateLimit = 60
DefaultMaxValueLen = 128
DefaultIncludeStack = false
DefaultStackMaxDepth = 5
Rules file schema (place the file as BepInEx/config/WhySoLaggy.fieldprobe.json):
{
"_schema": "WhySoLaggy.FieldProbe v1",
"_doc": "target=Type.Method; fields roots: __instance / __argN / __args / __result / __exception / TypeName; operators .Member ?.Member [N] .Count .Length",
"enabled": true,
"rateLimitPerRule": 60,
"maxValueLen": 128,
"includeStack": false,
"stackMaxDepth": 5,
"rules": [
{
"note": "Check which lantern prefab passes the slot filter",
"target": "LanternHelper.FindLitLanternSlot",
"fields": [
"__arg0?.name",
"__result",
"__result?.prefab?.name"
],
"includeStack": true,
"rateLimit": 30
},
{
"note": "Milk-invincibility: is the short-circuit firing?",
"target": "CharacterAfflictions.AddStatus",
"fields": [
"__arg0",
"__arg1",
"__instance.character.data.isInvincibleMilk",
"__result"
],
"rateLimit": 120
},
{
"note": "Which StatusField is applying Cold?",
"target": "StatusField.Update",
"fields": [
"__instance.gameObject.name",
"__instance.statusType",
"__instance.statusAmountPerSecond"
],
"rateLimit": 10
}
]
}
Expression DSL:
| Root | Meaning |
|---|---|
__instance |
Harmony __instance (the this) |
__arg0, __arg1, … |
Positional parameters |
__args |
Full argument array summary |
__result |
Return value (postfix) |
__exception |
Caught exception (postfix, may be null) |
SomeTypeName |
Fully-qualified type name to reach statics |
Operators: .Member (field/property), ?.Member (null-safe), [N] (array/list index), .Count, .Length.
Per-rule knobs: rateLimit, maxValueLen, includeStack, stackMaxDepth, enabled, note. All optional — fall back to the global defaults above.
Each invocation emits one FieldProbe event containing the target name and every expression's evaluated value (or an error tag). Set "enabled": false on a rule to keep it in the file but skip it.
A full working sample (used to diagnose the Faerie-Lantern and Milk-Invincibility bugs during development) ships as WhySoLaggy.fieldprobe.json in the mod source; copy it into BepInEx/config/ and tweak as needed.
Zero overhead when EnableFieldProbe=false.
Set [UI] ShowDashboard = true to draw a draggable GUILayout window showing:
When off, the entire IMGUI draw path is skipped — no cost.
EnableMemoryMonitor (default on): samples Profiler.GetTotalAllocatedMemoryLong() once per second, exposes the rate as AllocRateKBps on every FpsReport. Tells you at a glance whether a spike is CPU-bound or GC-bound.AbuseAlert events also include PhotonNetwork.GetPing(), so you can correlate lag with network RTT.[Logging]
LogVerbosity = Minimal # options: Minimal | Normal | Verbose
| Level | WhySoLaggy.log |
WhySoLaggy_Abuse.log |
|---|---|---|
| Minimal (default) | nothing | abuse alerts only (alerts always land, even in Minimal) |
| Normal | + periodic FPS/RPC reports | + periodic RPC reports, feeding-chain detail |
| Verbose | + every spike line, every patch timing | + every watched RPC detail record |
The CSV/JSONL structured logs are never gated by verbosity — they always capture everything for post-hoc analysis. Verbosity only affects the human-readable text logs.
PEAK runs a Master-relay model: every client RPC goes client → Master → targets, so on clients the visible sender is almost always the Master. On a Master install, WhySoLaggy unpacks the Photon event payload itself to recover the real origin:
RemoteRpcTrace — parses EventCode=200 Hashtable CustomData and extracts (methodName, viewID, realSenderActor) for every RPC on a short whitelist of high-risk methods. Emits one structured event per hit.SuspectedRequester* — when PhotonNetwork.Instantiate fires on the Master, the tracer scans the last 500 ms of RemoteRpcTrace buffer to find the client-side RPC that most plausibly caused the spawn (by method + viewID proximity). Writes SuspectedRequesterActor, SuspectedRequesterName, SuspectedRequesterRpc, SuspectedAgeMs into the InstantiateTrace row — this turns "Master spawned the item" into "Client X asked Master to spawn this via RPC Y".OwnershipChange — decodes EventCode 210 (request) / 211 (transfer) / 215 (update). The payload is int[2] = {viewID, otherActor}. Each event is logged as structured data; CheckOwnershipGrab cross-counts per-actor in a window and fires the OwnershipGrab alarm described above.ActorMethodHotspot — per-actor per-method RPC counter with its own fast window (CheckInterval) and slow window (ReportInterval). Top-N Actor×Method rows are added to every periodic report.These four diagnostics are the Master-only feature set flagged in the Testing status section — they compile cleanly and self-check, but have not yet been exercised in an actual hosted session. Treat their output as beta; on a client install these code paths stay dormant and cost nothing.
All files live under PEAK/BepInEx/.
| File | When written | Contents |
|---|---|---|
WhySoLaggy.log |
session | Human-readable perf log (gated by LogVerbosity) |
WhySoLaggy_Abuse.log |
session | Human-readable abuse + RPC log (gated by LogVerbosity) |
whysolaggy_data.csv |
session | 58-column event stream, auto-rotated at MaxLogFileSizeMB |
whysolaggy_events.jsonl |
session | JSON-per-line event stream, auto-rotated |
harmony_patches.csv |
startup once | Every Harmony patch in the process |
Open BepInEx/config/com.wuyachiyu.WhySoLaggy.cfg after first launch (or use any ModConfig UI).
[General]| Key | Default | Description |
|---|---|---|
| SpikeThresholdMs | 50 | Frame ms counting as a spike (16–200) |
| ReportIntervalSeconds | 10 | Seconds between FpsReport (5–60) |
| EnablePluginProfiling | false | Per-plugin Update timing. Opt-in. |
| EnablePatchProfiling | false | Per-patch method timing. Opt-in. |
| TopMethodCount | 10 | Top-N for perf reports (3–30) |
| MinReportMs | 0.1 | Patch-profiler low-cost filter |
| IgnorePluginGuids | (empty) | CSV of plugin GUIDs to skip |
| IgnorePatchMethods | (empty) | CSV of Type.Method names to skip |
| EnableMemoryMonitor | true | Sample GC alloc rate |
[AbuseDetection]| Key | Default |
|---|---|
| EnableAbuseDetection | true |
| CheckIntervalSeconds | 1.0 |
| ReportIntervalSeconds | 30.0 |
| InstantiateRateThreshold | 15 |
| DestroyRateThreshold | 20 |
| RpcRateThreshold | 50 |
| ObjectSpikeThreshold | 30 |
| ActorMethodRateThreshold | 20 |
| OwnershipGrabRateThreshold | 10 |
[RpcMonitor]| Key | Default | Description |
|---|---|---|
| EnableRpcMonitor | true | |
| TopMethodCount | 10 | Top-N in periodic report |
| WatchedRecordPerMethodCapacity | 32 | Ring buffer size per watched method |
| WatchedShowPerMethod | 6 | Records printed per watched method in report |
| ExtraWatchMethods | (empty) | CSV of extra method names to watch |
| PumpBatchSize | 32 | Queue items consumed per frame |
[Logging]| Key | Default |
|---|---|
| LogVerbosity | Minimal |
| MaxLogFileSizeMB | 10 |
[UI]| Key | Default |
|---|---|
| ShowDashboard | false |
[MethodTracer]| Key | Default |
|---|---|
| TraceMethodNames | (empty) |
| TraceMaxDepth | 5 |
| TraceRateLimit | 100 |
[FieldProbe]| Key | Default |
|---|---|
| EnableFieldProbe | false |
| RulesFile | WhySoLaggy.fieldprobe.json |
| DefaultRateLimit | 60 |
| DefaultMaxValueLen | 128 |
| DefaultIncludeStack | false |
| DefaultStackMaxDepth | 5 |
Upgrading from 1.0.2? BepInEx only applies new default values when a key is missing. To pick up the quieter defaults, either delete
com.wuyachiyu.WhySoLaggy.cfgor flipEnablePluginProfiling,EnablePatchProfilingtofalseandLogVerbositytoMinimalmanually.
By 1.0.3 the recommended workflow is tag-first: every event (native WhySoLaggy or any mod cooperating with it, e.g. LanternShootZombiesNight) now lands with a bracketed tag like [LitSync], [WARMTH_LOG], [FuelMath], [RPC_MON], ⚠ ABUSE ALERT. Slice by tag first, only then zoom out.
Fastest path for single-question diagnostics. One pass, zero tooling beyond the shell.
Text logs (Windows PowerShell / rg / grep):
# every abuse alert in this session
Select-String -Path BepInEx\WhySoLaggy_Abuse.log -Pattern '⚠ ABUSE ALERT'
# every lantern lit-state change on the host
Select-String -Path BepInEx\LogOutput.log -Pattern '\[LitSync\]'
# every failed warmth tick on the client
Select-String -Path BepInEx\LogOutput.log -Pattern 'WARMTH_LOG.*FAILED'
Structured logs (whysolaggy_events.jsonl + jq):
# top RPC senders
jq -r 'select(.type=="RpcCall") | .sender' whysolaggy_events.jsonl | sort | uniq -c | sort -rn
# all FieldProbe hits for one rule
jq 'select(.type=="FieldProbe" and .target=="StatusField.Update")' whysolaggy_events.jsonl
# frame spikes worse than 100ms
jq 'select(.type=="SpikeFrame" and .frameMs>100)' whysolaggy_events.jsonl
whysolaggy_data.csv has 58 fixed columns. Pivot by type, filter by method / sender, chart frameMs over time. Great for multi-session comparison and for sharing one screenshot with teammates.
Still useful, but only after you've narrowed the file down. Dumping a full multi-MB .log into a chat wastes tokens and buries the signal. Typical recipe:
Select-String / jq to extract the 50–500 lines around the incident."Lines below are tagged PEAK session logs (
[LitSync]= lantern sync,[WARMTH_LOG]= warmth tick,⚠ ABUSE ALERT= suspected flood). Tell me what went wrong, for which viewId, and in what order."
WhySoLaggy.log: lines with ! or !!! mark spikes; the ms number shows severity.WhySoLaggy_Abuse.log:
⚠ ABUSE ALERT — suspect nickname + ActorNumber follow[RPC_MON] — periodic RPC stats + watched detailSendFeedDataRPC detail: 喂食者=X#123 → 被喂者=Y#456, 物品=Z means X fed Y with ZLogOutput.log with lantern tags): search by [TagName] — see the real-world case section below for what the tags look like in practice.Real snippets from a 6-player session. Nicknames replaced with PlayerA–PlayerG (PlayerA = Master). The buffer format has since shifted to per-method rings, but the information shown is the same.
Startup
[21:47:25.473] [RPC_MON] Hooked PhotonNetwork.ExecuteRpc successfully
[21:47:25.473] [RPC_MON] RpcMonitor initialized (watched methods: 27) # historical snippet — 1.0.3 ships 65+
Abuse alert with suspect + top RPC methods
[21:47:44.510] ⚠ ABUSE ALERT: RPC flood! Rate: 85.0/s (threshold: 50/s)
[21:47:44.511] [ABUSE] Top RPC sources (by ActorNumber):
PlayerA#1: 42x
PlayerB#2: 10x
PlayerC#3: 8x
[21:47:44.511] [RPC_MON] Current-window top RPC methods:
SyncInventoryRPC: 25x
SetItemInstanceDataRPC: 9x
SetCharacterIdle_RPC: 6x
30s periodic report — top methods with payload + top senders
[21:47:55.246] [RPC_MON] Top 10 RPC methods this period:
SyncStatusesRPC: 82x avg=72B max=72B total=5904B [PlayerB#2:28, PlayerA#1:15, PlayerE#7:15]
SyncInventoryRPC: 76x avg=59B max=83B total=4531B [PlayerA#1:56, PlayerE#7:5, PlayerB#2:5]
ReceivePluginsFromHostRPC: 9x avg=1392B max=1392B total=12528B [PlayerA#1:9]
Watched method detail — sender, target GameObject path, parsed args
[37.6s] PlayerA#1 → RPC_SetThrownData payload=24B
target=(PlayerA#187) path=C_Pawn W(Clone)
detail=投掷者=PlayerA#10002, 力度=0.00
Feeding-chain trace — "who fed whom with which item"
[668.3s] PlayerC#3 → Consume payload=20B
target=(PlayerC#30040) path=Mushroom Lace(Clone)
detail=消耗者=PlayerC#30013, 物品=Mushroom Lace(Clone)#30040
[668.3s] PlayerC#3 → RemoveFeedDataRPC payload=20B
target=(PlayerC#30040) path=Mushroom Lace(Clone)
detail=喂食结束: 喂食者=PlayerC#30013
In each trace above the feeder viewID equals the consumer viewID — everyone ate their own food, no sneaky cross-player feeding. If someone ever feeds someone else, the two IDs differ and the anomaly stands out immediately.
Pulled from a co-op session between a Host (IsMasterClient=True) and a Client (IsMasterClient=False) running LanternShootZombiesNight on top of WhySoLaggy. The lines below come straight from BepInEx/LogOutput.log; viewId and lanternInstID are Photon/Unity internal IDs (not player-identifying) and are kept verbatim. No nicknames appear in these tags.
These tags are emitted by the lantern mod itself — WhySoLaggy's job here is to make them structured (CSV/JSONL, auto-rotated, AI-friendly) and to offer FieldProbe as a zero-recompile way to add more of them against any Type.Method target.
Host side — [LitSync] proves both sync channels fire
Channel 1 (LightLanternRPC) and Channel 2 (SetItemInstanceDataRPC → OnInstanceDataSet) each carry their own tag; the follow-up lit CHANGED line confirms the field actually flipped and the VFX GameObject (lightGO.active) matches.
[LitSync] OnInstanceDataSet: viewId=10007, FlareActive=False, lit=False, isMine=True
[LitSync] LightLanternRPC RECEIVED: viewId=10007, lit=True, isMine=True, lightGO.active=False
[LitSync] lit CHANGED: viewId=10007, False→True, isMine=True, lightGO.active=True
[LitSync] LightLanternRPC RECEIVED: viewId=10007, lit=False, isMine=True, lightGO.active=True
[LitSync] lit CHANGED: viewId=10007, True→False, isMine=True, lightGO.active=False
Reading the trio: the RPC arrives → lit flips → lightGO.active flips the same frame. If OnInstanceDataSet ever fires with FlareActive=True but lit stays False, that's a sync bug; if lit=True but lightGO.active=False, the VFX layer is desynced.
Client side — warmth lifecycle readable at a glance
[HuddleWarmth] STARTED: nearby=1, multiplier=0.5x, warmth=2.5s, interval=7s
[WARMTH_LOG] source=HuddleWarmth | nearby=1 | warmth=+2.5s | result=FAILED(no lit lantern)
[HuddleWarmth] STOPPED: nearby=0 < min=1
[HuddleWarmth] STARTED: nearby=1, multiplier=0.5x, warmth=2.5s, interval=7s
[FuelMath] currentFuel=118.484, delta=2.500, rawNew=120.984, maxFuel=120.000, overflow=0.984, lanternInstID=-45672
[WARMTH_LOG] source=HuddleWarmth | nearby=1 | multiplier=0.5x | warmth=+2.5s | interval=7s | result=SUCCESS
Three states in ten lines: first tick fails because the client hadn't lit the lantern yet (FAILED(no lit lantern)); the pair then breaks radius and the tracker correctly STOPPED; once they regroup and the lantern is lit, [FuelMath] quantifies the cap-waste (overflow=0.984) and the next WARMTH_LOG shows result=SUCCESS.
Client side — alternative warmth source with distance + overflow
[FuelMath] currentFuel=116.986, delta=5.000, rawNew=121.986, maxFuel=120.000, overflow=1.986, lanternInstID=-46484
[WARMTH_LOG] source=BugleRestore | warmth=+5.0s | dist=9.3m | result=SUCCESS
[FuelMath] currentFuel=120.000, delta=5.000, rawNew=125.000, maxFuel=120.000, overflow=5.000, lanternInstID=-47052
[WARMTH_LOG] source=BugleRestore | warmth=+5.0s | dist=10.3m | result=SUCCESS
The source= + dist= + overflow= triad makes every warmth event post-hoc auditable: which mechanic fired it, how far the recipient was, and how much fuel (if any) was wasted at the cap. Pair this with WhySoLaggy's whysolaggy_events.jsonl and you can replay a full session in jq, Excel, or an AI chat without ever re-running the game.
Want these exact fields without touching source? A
FieldProberule againstLanternHelper.AddPlayerLanternFuel/HuddleWarmth.Update/StatusField.Updateproduces oneFieldProbeevent per call with the same level of detail — shipped through the same CSV/JSONL pipeline.
WhySoLaggy.dll into PEAK/BepInEx/plugins/.WhySoLaggy.fieldprobe.json under PEAK/BepInEx/config/ and set EnableFieldProbe=true.No other mods required.
WhySoLaggy works on any install, but who sees what differs between a host (Master Client) and a regular client. Quick guide:
| Feature | Client install | Master install |
|---|---|---|
| FPS / SpikeFrame / GC alloc rate | ✅ full | ✅ full |
| Plugin / Patch Profiler | ✅ full (local plugins) | ✅ full (local plugins) |
| Local Instantiate / Destroy / RPC rate | ✅ full | ✅ full |
| InstantiateTrace (caller stack) | ✅ full (local spawns) | ✅ full (all spawns pass here) |
| Full RPC Monitor + watched records | ✅ full (sender usually = Master) | ✅ full (sender = real actor) |
| FieldProbe / MethodTracer / Harmony scan | ✅ full | ✅ full |
| Abuse alerts + red on-screen banner | ✅ full | ✅ full |
| On-Screen Dashboard | ✅ full | ✅ full |
| RemoteRpcTrace (real client actor) | ⚪️ dormant | 🔍 Master-only, beta |
Instantiate SuspectedRequester* |
⚪️ dormant | 🔍 Master-only, beta |
| OwnershipChange / OwnershipGrab alarm | ⚪️ dormant | 🔍 Master-only, beta |
| ActorMethodHotspot alarm | 🟡 counts but sender = Master | 🔍 full Actor×Method, beta |
TL;DR
InstantiateTrace.TraceStack with the host's RemoteRpcTrace.SenderActor by viewID and you get a full end-to-end trail.All Master-only paths are null-safe and wrapped in try/catch; on a client install they short-circuit at the IsMasterClient check and consume no CPU.
Measured on an Intel i5-12400 @ 4.4 GHz, PEAK 6-player session. All numbers are amortised per second unless noted.
| Scenario | CPU (ms/s) | GC (KB/s) | Disk (KB/s) | Resident (KB) |
|---|---|---|---|---|
| Normal co-op, default config | 1.0–1.5 | 2–4 | 1–2 | ≈ 450 |
| Item-spam session (15–30 Inst/s) | 2.5–4.0 | 4–8 | 3–6 | ≈ 600 |
| Extreme RPC flood (≥ 300/s) | 6–10 | 8–15 | 10–20 | ≈ 800 |
With EnablePluginProfiling=true |
+3–6 ms/s | +1–2 | — | — |
With EnablePatchProfiling=true |
+2–4 ms/s (throttled) | +1 | — | — |
With ShowDashboard=true |
+0.3–0.6 ms/s | negligible | — | — |
Hot path breakdown (default config, normal session):
| Component | Share | Notes |
|---|---|---|
| RpcMonitor main-thread Pump | ≈ 40 % | 32 items/frame, lock-free queue |
| StructuredLogger CSV+JSONL writer | ≈ 25 % | flush every 10 events, 10MB rotation |
NetworkAbuseDetector OnEvent |
≈ 20 % | counters + whitelist lookups |
| FPS tracker + memory sampler | ≈ 10 % | one unscaledDeltaTime / frame |
| Everything else (dashboard, FieldProbe idle, …) | ≈ 5 % |
Opt-in modules that truly cost zero when off: PluginProfiler, PatchProfiler, PerformanceDashboard, MethodTracer, FieldProbe — their hooks/IMGUI paths are not installed unless the corresponding config flag is true.
Throughput ceiling: the CSV+JSONL writer has been benchmarked at ≈ 8,000 events/s sustained before the flush queue starts lagging. In normal play the observed rate is < 50/s; extreme floods peak around 500/s.
Keep the default config on for 7×24 captures — the overhead is lower than a single cosmetic mod's
Updateloop. Only flipEnablePluginProfiling/EnablePatchProfilingon when actively hunting a specific offender.
OnRemoteRpcEvent Hashtable unpack — captures the real client Actor behind a Master-relayed RPCPhotonNetwork.Instantiate back to the client RPC that most likely triggered it (SuspectedRequesterActor/Name/Rpc)whysolaggy_events.jsonl (filter type=RemoteRpcTrace / OwnershipChange / InstantiateTrace) so the Master-side paths can be validated against real data.Spotted an RPC that looks suspicious but isn't on the watched list yet? Drop the method name in the QQ group — if it makes sense I'll add it in the next update. Or add it yourself via ExtraWatchMethods without waiting.