WhySoLaggy
Find which mod causes lag and who is bombing your room. Profilers, RPC + abuse detectors, structured CSV+JSONL logs, Harmony conflict scan, JSON-driven field probe.
By WUYACHIYU
| Last updated | 3 weeks ago |
| Total downloads | 868 |
| Total rating | 1 |
| Categories | Mods Client Side All Clients |
| Dependency string | WUYACHIYU-WhySoLaggy-1.0.3 |
| Dependants | 0 other packages depend on this package |
This mod requires the following mods to function
BepInEx-BepInExPack_PEAK
BepInEx pack for PEAK. Preconfigured and ready to use.
Preferred version: 5.4.2403README
WhySoLaggy
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.
Quick start
- Install BepInExPack PEAK.
- Drop
WhySoLaggy.dllintoPEAK/BepInEx/plugins/. - Launch the game, play normally, then exit.
- Open
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.
Features
Each feature has a default state. Features marked opt-in cost zero when disabled.
1. FPS Monitor — always on
- Tracks frame time every frame; any frame exceeding
SpikeThresholdMs(default 50ms) is logged as a spike and written as aSpikeFrameevent. - Every
ReportIntervalSeconds(default 10s) emits anFpsReportevent with average/p95/p99 frame ms, window average, and (if memory monitor is on)AllocRateKBps. - Overhead: one
Time.unscaledDeltaTimeread per frame. Negligible.
2. Plugin Profiler — opt-in (default off in 1.0.3)
- When
EnablePluginProfiling=true, Harmony-patchesUpdate/LateUpdate/FixedUpdateon every BepInEx plugin'sMonoBehaviours and records their per-frame cost. - Periodic report lists the slowest plugins by average ms, plus total calls.
- Use
IgnorePluginGuids(comma-separated) to permanently mute noisy plugins. Example:IgnorePluginGuids = com.example.ui-mod,com.example.minimap - Why off by default: instrumenting every mod's
Updateadds measurable self-overhead. Turn on only when diagnosing.
3. Patch Profiler — opt-in (default off in 1.0.3)
- When
EnablePatchProfiling=true, wraps every Harmony-patched target method with prefix/postfix timing. - Throttling: after a method has been sampled >20 times and its average sits below
MinReportMs(default 0.1ms), only every 10th call is recorded afterwards. Cuts self-overhead by ~70–90% in busy scenes. IgnorePatchMethodslets you skip known noisy methods:IgnorePatchMethods = Player.Update,Camera.LateUpdate- Null-safe: a null
__originalMethodis logged as"(unknown)"instead of throwing inside Harmony.
4. Room-Bombing Detection — always on
- Monitors three per-second rates: Instantiate, Destroy, RPC. Also tracks absolute scene object count for spike detection.
- When any rate exceeds its threshold, an
⚠ ABUSE ALERTis written toWhySoLaggy_Abuse.logtogether with the offending player's nickname + ActorNumber and the current top-5 RPC method names for context. - A red on-screen banner shows the alert for 12s (auto-fades). Language follows system locale (中/EN).
- Observation only — never kicks, blocks or interferes.
- New in 1.0.3 — two extra alarms layered on top (see also the Master-side diagnostics section):
ActorMethodHotspot— the same actor sent the same RPC ≥ActorMethodRateThresholdtimes in one check window (default 20/s). Pinpoints which client is flooding which method, not just the total.OwnershipGrab— one actor pulled ≥OwnershipGrabRateThresholdPhotonView ownerships in one window (default 10/s). Surfaces silent "I own everything now" griefing patterns.
- InstantiateTrace — every
PhotonNetwork.Instantiateis 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.
5. Full RPC Monitor — always on
Hooks PhotonNetwork.ExecuteRpc and tracks every RPC.
- Two buffers per method:
_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 records
- 65+ built-in watched methods covering feeding / healing / status / death / revive / teleport / physics impulse / grab-kick-carry / item pickup-drop-throw / inventory / prefab spawn / end-game / ownership / tornado capture / campfire. They record sender, target GameObject + scene path, parsed arguments, payload bytes.
- Feeding-chain tracing:
SendFeedDataRPC → RemoveFeedDataRPC → GetFedItemRPC → Consumepreserved in order, so you can see "who fed whom with which item". - Thread-safe: Photon callback thread only enqueues into a lock-free
ConcurrentQueue. All dictionary/PhotonView work happens on the main thread, batched atPumpBatchSize(default 32/frame). - Add custom watched methods without recompiling:
[RpcMonitor] ExtraWatchMethods = MyModRPC_Foo,AnotherModRPC_Bar - Hot path <500ns per RPC, no allocations.
6. Structured Logging (CSV + JSONL) — always on
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 forjq/ any AI
Example 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.
7. Startup Harmony Conflict Scan — always on
Once at startup, the mod snapshots every Harmony-patched method in the process to:
BepInEx/harmony_patches.csv— columns:TargetMethod, PatchType, OwnerHarmonyId, Priority- structured log (
HarmonyPatchMapevents)
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.
8. Method Stack Tracer — opt-in (default disabled)
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.
9. FieldProbe — opt-in (default disabled), JSON-driven
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.
10. On-Screen Dashboard — opt-in (default off)
Set [UI] ShowDashboard = true to draw a draggable GUILayout window showing:
- live FPS, frame time, window-average frame ms
- GC allocation rate (KB/s, if memory monitor is on)
- current watched-RPC summary
- most recent abuse alert
When off, the entire IMGUI draw path is skipped — no cost.
11. GC Allocation + Ping — always on
EnableMemoryMonitor(default on): samplesProfiler.GetTotalAllocatedMemoryLong()once per second, exposes the rate asAllocRateKBpson everyFpsReport. Tells you at a glance whether a spike is CPU-bound or GC-bound.- Periodic reports +
AbuseAlertevents also includePhotonNetwork.GetPing(), so you can correlate lag with network RTT.
12. Log Verbosity — Minimal by default in 1.0.3
[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.
13. Master-side Relay Diagnostics — always on (1.0.3, beta)
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— parsesEventCode=200HashtableCustomData and extracts(methodName, viewID, realSenderActor)for every RPC on a short whitelist of high-risk methods. Emits one structured event per hit.- Master-side
SuspectedRequester*— whenPhotonNetwork.Instantiatefires on the Master, the tracer scans the last 500 ms ofRemoteRpcTracebuffer to find the client-side RPC that most plausibly caused the spawn (by method + viewID proximity). WritesSuspectedRequesterActor,SuspectedRequesterName,SuspectedRequesterRpc,SuspectedAgeMsinto theInstantiateTracerow — 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 isint[2]={viewID, otherActor}. Each event is logged as structured data;CheckOwnershipGrabcross-counts per-actor in a window and fires theOwnershipGrabalarm 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.
Output files
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 |
Configuration (key items)
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.
How to analyse the logs
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.
Option A — grep / jq by tag (recommended)
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
Option B — open the CSV in Excel / Power BI / DuckDB
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.
Option C — hand a curated slice to an AI
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/jqto extract the 50–500 lines around the incident.- Paste that slice plus this prompt:
"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." - JSONL paste-through works the same — each line is already a self-describing object.
Option D — read the plain-text log yourself
WhySoLaggy.log: lines with!or!!!mark spikes; themsnumber shows severity.WhySoLaggy_Abuse.log:⚠ ABUSE ALERT— suspect nickname + ActorNumber follow[RPC_MON]— periodic RPC stats + watched detailSendFeedDataRPCdetail:喂食者=X#123 → 被喂者=Y#456, 物品=Zmeans X fed Y with Z
- Any cooperating mod log (e.g.
LogOutput.logwith lantern tags): search by[TagName]— see the real-world case section below for what the tags look like in practice.
Sample logs — real data (names redacted)
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.
Real-world case: lantern lit-sync + warmth diagnostics (dual-client)
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.
Installation
- Install BepInExPack PEAK (v5.4.2403+).
- Drop
WhySoLaggy.dllintoPEAK/BepInEx/plugins/. - (Optional) For FieldProbe, place your
WhySoLaggy.fieldprobe.jsonunderPEAK/BepInEx/config/and setEnableFieldProbe=true.
No other mods required.
Deployment: Master vs Client
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
- Client-only install — you get the full performance lab, local-spawn forensics and RPC sample buffer; the "real client sender" stays hidden because Master has already rewritten it.
- Master install — you additionally get the four relay-decoding diagnostics that pinpoint which client sent the RPC, grabbed the ownership or triggered the Instantiate. These are flagged beta until a hosted-session capture confirms them.
- Dual install (both players run the mod) — ideal for forensics. Correlate the client's
InstantiateTrace.TraceStackwith the host'sRemoteRpcTrace.SenderActorby 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.
Performance baseline (1.0.3)
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.
Notes
- The mod waits ~5s after launch before hooking, so other mods can finish loading first.
- All advanced features (Plugin/Patch Profilers, Dashboard, MethodTracer, FieldProbe) are off by default in 1.0.3. Flip them on only when you need them.
- Observation-only: nothing is kicked, blocked or modified in the simulation. Perfect for post-incident evidence.
- Net overhead in default config is lower than 1.0.2 thanks to the Minimal verbosity + quiet profilers.
⚠ Testing status (1.0.3)
- Every 1.0.3 feature was validated from the client side during development. Client-only functionality (FPS monitor, RPC monitor, local Instantiate/Destroy/RPC rates, FieldProbe, MethodTracer, Harmony conflict scan, structured logging) is fully exercised.
- A subset of new diagnostics can only do real work when the install is on the Master Client:
OnRemoteRpcEventHashtable unpack — captures the real client Actor behind a Master-relayed RPC- Master-side Instantiate requester correlation — ties a
PhotonNetwork.Instantiateback to the client RPC that most likely triggered it (SuspectedRequesterActor/Name/Rpc) - PhotonView Ownership audit — EventCode 210 / 211 / 215
- Actor×Method hotspot detector — per-actor per-method RPC rate alarm
- Those Master-only paths compile cleanly, self-check without exceptions and the code has been reviewed, but have not yet been exercised in an actual hosted session. Treat their output as beta until a host-side capture confirms them; client-side observations are unaffected.
- If you happen to host a session with this build, please send the resulting
whysolaggy_events.jsonl(filtertype=RemoteRpcTrace / OwnershipChange / InstantiateTrace) so the Master-side paths can be validated against real data.
Contact & feature requests
- Author: 乌鸦吃鱼
- QQ Group: 1093172647
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.