Scuffed Diagnostics (ScuffedDiag) is an operator tool. It measures tick lag, memory, garbage collection, per-player network traffic and entity populations, writes evidence to disk when something goes wrong, and turns each capture into a shareable report page. It has no player-facing features, so there is no gameplay page. Everything below is for staff.
- Plugin version: 3.13.12 (plugin name
ScuffedDiag)
What it does
ScuffedDiag is read-only. It never changes game state. It has three layers:
| Layer | What runs | When |
|---|---|---|
| Always-on collectors | Per-world tick intervals (TPS), JVM pause detector, allocation-rate sampler, entity census and density hotspots, per-world load level, per-player network watch and per-stream network accounting, network thread gauge, container memory guard, lag watchdog, TPS guard (auto report), 5-minute heartbeat | From boot |
| Recording | Spike records, freeze stack sampler, incident snapshots, particle / packet / entity-update taps, disconnect records, ping spikes | Only while a capture window or /scuffeddiag on is active (off by default) |
| Capture windows | A time-boxed bundle: 30-second time series plus Refixes per-system timing, written to a session folder and turned into a report page | /scuffeddiag report, /scuffeddiag capture, automatic baseline and stall windows, automatic low-TPS reports, or a remote call through the API |
Every /scuffeddiag subcommand also runs from the server console.
Requirements
| Item | Version | Type |
|---|---|---|
| Hytale server | >=0.7.0-pre.0 <0.8.0 | Required |
Hytale:AssetModule | any | Required (the plugin ships a small asset pack for the entity census popup) |
| Refixes (Endless) | any recent version | Optional. When present, captures include deterministic per-system and per-resource tick timing, which names the system that owned a freeze. Without it the freeze charts still work but nothing is named |
| spark | any | Optional. /scuffeddiag report and /scuffeddiag capture also run spark profiler start ... for the full call tree; if spark is absent that command prints its own unknown-command line and the rest of the capture is unaffected |
Notes:
- Several tools read Linux files (
/proc/self/status, cgroup memory files). On a host without them the container memory guard stays idle and the native-memory dump omits those sections. - ZGC-specific tuning (soft max heap) only applies when the JVM exposes that flag. The boot log states whether the JVM accepted it.
- The heap, JFR and native-memory commands use the JVM's own diagnostic interface inside the process. They need no shell or
jcmd, but a host that blocks JFR or the diagnostic MBean will refuse them.
Admin & Staff Commands
Root command: /scuffeddiag. All subcommands are staff-only (see Permissions).
World-scoped commands (entities, entitieschat, interactions) take an optional world name: an exact name, or a substring that matches exactly one world. With no name, a player gets their own world; the console gets every world that has players (busiest first, at most 3), or the first 3 worlds if nobody is online.
Status and live readouts
| Command | Description |
|---|---|
/scuffeddiag status | Per-world TPS, slow-tick percentage and tick interval (mean, p95, max) over the last 60 seconds, plus the busiest network thread. Worlds with no players show "no ticks observed" |
/scuffeddiag lagreport | One-screen readout from the always-on collectors: server line, worlds, entities by class, top NPC roles, hotspots, network, load transitions, last stall. Also written to <day>/lagreport-<UTC>.txt. Names players and their coordinates |
/scuffeddiag spikes | Recent over-budget ticks with the context captured at the time (needs recording on) |
/scuffeddiag disconnects | The last 20 disconnects with reason, ping, queue and packet context (needs recording on) |
/scuffeddiag net | Server-wide outbound and inbound bandwidth, top packet types, per-player ping, queue and rate |
/scuffeddiag net <player> | One player's live readout: Default, Chunks and WorldMap streams, QUIC connection stats, pings, spawns and page opens |
/scuffeddiag packets | Full outbound packet-type breakdown, ranked by bytes per second, with the particle share |
/scuffeddiag particles | Per-effect particle send rates and the top effect per recipient (needs the particle tap) |
/scuffeddiag entityupdates | Entity-update traffic split by component type and recipient (needs recording on) |
/scuffeddiag entities [world] | Entity census by NPC role. Opens a popup for a player in their own world, text everywhere else |
/scuffeddiag entitieschat [world] | The same census as plain chat or console text |
/scuffeddiag interactions [world] | Which blocks and fluids drive per-tick collision interaction chains. Written to interactions-*.jsonl in the session folder. A single named world gets a full sweep; several worlds at once are time-capped to 15 ms each |
/scuffeddiag config <show|reload> | Show every diag-config.json key (changed values are marked with their default) or re-read the file live |
/scuffeddiag atlas [top N | model N | lint | dupes | <pack>] [fresh] | Which packs load the client texture atlas, from PNG headers only. top lists the heaviest textures, model the heaviest model textures, lint model textures that break the multiple-of-32 rule plus textures referenced but not shipped, dupes byte-identical copies, <pack> one pack. N is 1 to 200 (default 20). The scan is cached for 5 minutes; fresh rescans. Full report: atlas-report.txt in the plugin folder |
Recording and captures
| Command | Description |
|---|---|
/scuffeddiag on | Start recording into a new session folder. There is no time limit |
/scuffeddiag off | Stop all recording paths at once (taps, samplers, session writes, per-system timer). The watchdog and the TPS guard keep running |
/scuffeddiag report <minutes> | Time-boxed full bundle, 1 to 30 minutes. Records, writes SUMMARY.txt and README.txt, stops and disables recording by itself. Also starts spark. Preempts a running automatic window |
/scuffeddiag capture <minutes> | Arms a capture window, 1 to 120 minutes. Samples every 30 seconds. Restores the previous recording state when it ends |
/scuffeddiag capture stop (or off) | End a window early |
/scuffeddiag threshold <ms> | Tick-spike threshold in milliseconds (minimum 1, default 50). Not saved across restarts |
/scuffeddiag sampler <on|off> | Freeze stack sampler switch. Turned on and off with recording |
/scuffeddiag particletap <on|off> | Particle inspector tap switch |
/scuffeddiag allocprofile <seconds> | JFR allocation profile, 10 to 300 seconds, written as alloc-<date>.jfr plus ALLOC_SUMMARY.txt into the session folder. Shows which classes and call sites generate garbage |
/scuffeddiag web <status|link|build|list|reload> | Report viewer controls, see Report viewer |
report and capture do not run at the same time as another report. A second report while one is in progress is refused.
Unattended capture
| Command | Description |
|---|---|
/scuffeddiag watch <on|off|status> | The always-on lag watchdog (on by default) |
/scuffeddiag watch <attribute-on|attribute-off> | Retroactive attribution: a stall of 4 s or more arms Refixes' meters for 2 minutes (at most once per 10 minutes) |
/scuffeddiag auto (or status) | Show baseline and stall window settings |
/scuffeddiag auto <on|off> | Enable or disable both automatic window types |
/scuffeddiag auto baseline <durationMin> <periodMin> | Set baseline length (1 to 6 min) and period (10 to 720 min) |
/scuffeddiag auto baseline off | Disable baseline windows only |
/scuffeddiag auto stall <on|off|minutes> | Enable, disable or set the length of stall windows (1 to 10 min) |
/scuffeddiag autoreport (or status) | Show the TPS guard state, trigger, last reading and cooldown |
/scuffeddiag autoreport <on|off> | Enable or disable the automatic 1-minute low-TPS report |
/scuffeddiag autoreport <TPS> | Set the trigger TPS (clamped to 1 to 29, default 15) |
/scuffeddiag autoreport test | Record a test bundle now. Does not start the cooldown |
Memory and JVM tools
These talk to the JVM directly. Some pause the server, so run them on purpose.
| Command | Description |
|---|---|
/scuffeddiag softmax <GB> | Set the ZGC SoftMaxHeapSize floor live. See Heap soft-max guard |
/scuffeddiag heaphisto <rows> | Live-object class histogram (1 to 200 rows printed to the console). Full table in heaphisto-<ms>.txt in the day folder. Collects first and pauses the server for a few seconds on a large heap |
/scuffeddiag nmt <label> | Native memory dump to nmt-<label>-<ms>.txt: NMT summary, buffer pools, non-heap pools, RSS lines and cgroup memory.stat. NMT needs -XX:NativeMemoryTracking=summary on the JVM command line; the other sections work without it |
/scuffeddiag nmt baseline | Record an NMT baseline. Later dumps add a summary.diff against it |
/scuffeddiag trim | Run glibc malloc_trim (no GC) and print RssAnon before and after |
/scuffeddiag leakhunt <minutes> | In-process JFR old-object sample with path-to-GC-root, 1 to 60 minutes. Writes scuffeddiag-leak-<ms>.jfr to the day folder when done |
/scuffeddiag leakworlds | Diff the engine store registries against the loaded worlds and name any removed world still pinned in memory. A result of zero orphans does not prove there is no leak |
/scuffeddiag heapdump | Gzipped live-object HPROF dump in the day folder. Stalls the server for the whole dump. Test servers only |
Permissions
| Node | Effect |
|---|---|
scuffeddiag.command | Allows every /scuffeddiag subcommand. Server operators (the OP group) always pass without it |
Who may run commands:
- The server console is always allowed.
- A player needs OP or
scuffeddiag.command, granted to the user or to one of their groups. - Any other sender (for example a chat bridge) is judged by its own
hasPermission("scuffeddiag.command")answer. If a bridge reports every permission as granted, anyone who can reach the bridge's command relay can run these commands, so keep that relay limited to an admin role.
The lag watchdog sends its in-game warning only to online players who have OP or scuffeddiag.command.
Configuration
ScuffedDiag has two files in mods/ScuffedDiag/. Both are created with defaults on first boot.
diag-config.json
One flat JSON object of dotted keys. Keys added by a later version are merged in on load without touching values you changed. /scuffeddiag config reload re-reads the file with no restart. A value of the wrong type falls back to its default, and an unreadable file leaves the file untouched and runs on defaults.
Population census and hotspots
| Key | Default | Effect |
|---|---|---|
census.enabled | true | Run the entity census |
census.intervalSec | 60 | Seconds between census cycles per world (minimum 15) |
census.writeIntervalSec | 300 | Seconds between census rows written to census-*.jsonl per world (minimum 60) |
census.budgetUs | 750 | World-thread time per slice in microseconds (minimum 100). A large world is read over several ticks |
census.minEntitiesWithoutPlayers | 500 | A world with no players is scanned only if it holds at least this many entities |
census.maxWorlds | 16 | Most worlds scanned per cycle |
hotspots.topN | 5 | Chunks listed per world (1 to 20) |
hotspots.alertNpcs | 60 | A chunk with at least this many NPCs writes a hotspots-*.jsonl row and a log warning, once per chunk per 10 minutes. 0 disables |
hotspots.afkMinutes | 5 | A player who stays within afkMoveBlocks for this long is marked AFK in hotspot rows |
hotspots.afkMoveBlocks | 2.0 | Movement radius in blocks that resets the AFK timer |
Per-player network watch
| Key | Default | Effect |
|---|---|---|
netwatch.enabled | true | Network watch |
netwatch.intervalSec | 5 | Sampling interval while recording is off |
netwatch.outBytesPerSecFlag | 1000000 | A player sending more than this per second is flagged. 0 disables this check |
netwatch.interactionPacketsPerSecFlag | 200 | Flag for interaction-chain packets per second. 0 disables |
netwatch.interactionQueueFlag | 500 | Flag for an interaction backlog. 0 disables. The engine starts dropping at 1000 |
netwatch.flagCooldownSec | 60 | Minimum time between flags for one player (minimum 5) |
netwatch.rollupIntervalSec | 60 | Interval of the top-5 consumer rollup in net-*.jsonl (minimum 10) |
Allocation profile on an unattended low-TPS report
| Key | Default | Effect |
|---|---|---|
allocAuto.enabled | true | Attach a JFR allocation profile to an automatic report |
allocAuto.seconds | 60 | Profile length (10 to 300) |
allocAuto.cooldownMinutes | 60 | Minimum time between automatic profiles (minimum 5) |
allocAuto.maxFileMB | 32 | Size cap of the .jfr file (4 to 256) |
allocAuto.minFreeDiskMB | 2048 | Skip the profile when less disk than this is free |
Stall evidence retention (mods/ScuffedDiag/stalls/)
| Key | Default | Effect |
|---|---|---|
stalls.keep | 30 | Newest folders kept per kind (stall windows and lock dumps counted separately) |
stalls.maxAgeDays | 14 | Folders older than this are deleted. 0 turns the age rule off |
stalls.maxTotalMB | 1024 | Oldest folders are deleted until the total is under this. 0 turns the size rule off |
Lock dump
| Key | Default | Effect |
|---|---|---|
lockdump.enabled | true | Take a full thread and lock-owner dump on a tick freeze or world hang |
lockdump.cooldownSec | 300 | Minimum time between dumps, shared by all triggers (minimum 30) |
Load level
| Key | Default | Effect |
|---|---|---|
load.enabled | true | Compute the OK / SOFT / HARD level per world |
load.softTps | 24.0 | Smoothed TPS at or below this means SOFT |
load.hardTps | 12.0 | Smoothed TPS at or below this means HARD (a hung world also counts as HARD) |
load.softMemFrac | 0.88 | Memory pressure at or above this fraction means SOFT |
load.hardMemFrac | 0.95 | Memory pressure at or above this fraction means HARD |
load.clearSec | 30 | A lower level must hold this long before the level drops (minimum 5) |
Per-stream network accounting
| Key | Default | Effect |
|---|---|---|
net.enabled | true | Per-player, per-stream accounting |
net.quicStats | true | Read each player's QUIC stats (bytes, loss, retransmits, RTT, congestion window) every second |
net.spawnTracking | true | Count newly visible entities per player |
net.spawnBytes | true | Include the bytes of those spawns |
net.histIntervalSec | 10 | Interval of the entity distance-band histogram |
net.histBudgetUs | 500 | World-thread budget for that pass in microseconds (minimum 50) |
net.pageEvents | true | Log custom page opens and large page updates |
net.pageEventMinBytes | 32768 | Page updates of at least this size are logged |
net.senderSampleSec | 10 | Interval at which the sending plugin's stack is sampled per page class or packet type |
net.burstRawBytes | 300000 | A player second at or above this many raw bytes per second writes a burst event. 0 disables |
net.burstCooldownSec | 30 | Minimum time between burst events for one player |
net.burstsPerMinute | 30 | Server-wide burst events per minute |
net.rollupSec | 60 | Interval of the netstream rollup (minimum 10) |
net.rollupTopPlayers | 10 | Players listed per rollup |
net.dumpTopPlayers | 20 | Players whose 10-minute timeline goes into a lag report or capture |
net.retainDepartedSec | 600 | How long a departed player's timeline is kept |
net.eventMaxMBPerDay | 32 | Daily cap for netev events |
net.rollupMaxMBPerDay | 32 | Daily cap for netstream rollups |
The two daily caps are counted in memory and start over at every boot, so a day with restarts can write more than one cap's worth. Events and rollups have separate caps so a flood of events cannot stop the rollups.
Network thread gauge
| Key | Default | Effect |
|---|---|---|
netThread.enabled | true | Sample the ServerWorkerGroup thread that runs every player's connection |
netThread.warnBusyFraction | 0.70 | Log a warning when the busiest network thread stays above this share of wall time for 10 seconds (at most once per minute). It also raises the load level: SOFT at this fraction, HARD at 90% |
web-config.json
Settings for the report viewer. Written once when absent, so later boots never overwrite your edits.
| Key | Default | Effect |
|---|---|---|
enabled | true | Serve reports over HTTP. When false, reports are still written to disk |
bindAddress | 0.0.0.0 | Interface to bind. Use 127.0.0.1 when a proxy or tunnel fronts the viewer |
port | 0 | Listen port. 0 derives it as the server's game port plus portOffset |
portOffset | 2 | Offset from the game port when port is 0 (0 to 4) |
publicHost | empty | Hostname or IP that outsiders use to reach this server. The port is appended for you |
publicBaseUrl | empty | Full base URL used as written, with no port added. Overrides publicHost. For a reverse proxy or tunnel |
linkTtlDays | 7 | How long a link works (1 to 7). It cannot exceed the 7-day folder retention |
autoGenerate | true | Build report.html automatically when a window closes |
allowBundleDownload | true | Allow the "Download bundle" zip of the whole session folder |
Applied by /scuffeddiag web reload without a restart: publicHost, publicBaseUrl, autoGenerate, allowBundleDownload. A change to enabled, bindAddress, port, portOffset or linkTtlDays needs a restart; the reload message lists any such keys that differ on disk.
Fixed values (no key)
| Setting | Value |
|---|---|
| Tick budget | 33.3 ms (30 TPS) |
| Spike threshold | 50 ms, changed with /scuffeddiag threshold |
| Incident threshold | 150 ms. A freeze this long, while recording, takes an incident snapshot of that world (at most one per world per 3 seconds) |
| Freeze sampler | Starts once a tick passes about 40 ms, samples the world thread every 20 ms, keeps up to 6000 samples |
| Session size cap | 120 MB. A session that hits it stops writing and drops a CAPPED.txt |
Detection and capture behavior
Lag watchdog
Polls every 2 seconds, does only cheap reads while the server is healthy, and writes one compact line to <day>/watch-<date>.jsonl when a trigger fires. At most one record per 2 minutes (30 seconds for hangs). Online staff get an in-game warning at most once per 10 minutes. It never starts a capture itself. It stands down while an operator's own recording is on, but keeps running during automatic windows.
| Trigger | Fires when |
|---|---|
heap-pressure | The lowest heap use over 3 minutes stays at 92% of -Xmx or more for 3 polls. Measured on the floor, not on current use, because ZGC rides high on dead objects |
tick-freeze | A tick interval of 1000 ms or more on a world with players |
jvm-pause | A JVM-wide pause of 1000 ms or more in the last 5 seconds (GC, safepoint or host) |
low-tps | Worst world TPS below 20 for 3 polls (about 6 seconds) |
mass-disconnect | 3 or more disconnects within 20 seconds |
rate-limit | A rate-limit disconnect reason within the last 20 seconds |
backpressure | A player's outbound queue at 2000 packets or more |
ping-storm | 2 or more players with a tick ping of 1000 ms or more |
world-hang | A world's in-progress tick (or an empty world's tick counter) is 5 seconds old or more. The record includes the world thread's live stack. One record per hang episode |
A tick-freeze or world-hang also takes a thread and lock-owner dump into stalls/lockdump-<UTC>-<trigger>/threads.txt (plus a locks-*.jsonl line naming deadlock patterns such as a read-to-write lock self-deadlock). The dump skips lock synchronizers on purpose, because collecting them forces a heap-wide safepoint.
Automatic capture windows
| Window | Default | Opened by |
|---|---|---|
| Baseline | 3 minutes every 30 minutes, first one 10 minutes after boot (about 10% duty) | A timer. Its value is a healthy comparison window from the same server and day |
| Stall | 2 minutes, at most one per 10 minutes | A tick-freeze of 4000 ms or more, a low-tps trigger, or a world-hang |
Automatic windows never take over from an operator: if recording is on or a capture is running, they do nothing. If a stall window cannot open, the watchdog arms Refixes' meters alone. Refixes' meters record forward, so a stall window explains the next stall in the episode, not the one that opened it.
Baseline folders are <day>/auto-baseline-<UTC>/ (newest 24 kept). Stall windows are stalls/auto-stall-<UTC>/, outside the day folders so the 7-day sweep cannot remove them.
Automatic low-TPS report (TPS guard)
On by default. Every 5 seconds it reads the mean tick interval over the newest 90 ticks of each world that has players (at least 30 recorded ticks). A world whose last tick is 10 seconds old counts as 0 TPS. When the worst world stays at or below the trigger (default 15 TPS) for 15 seconds, it records a 1-minute report into <day>/autoreport-<UTC>/, attaches an allocation profile (see allocAuto.*), and writes a LAGREPORT.txt into the folder. Cooldown between automatic reports is 20 minutes, and the newest 24 autoreport-* folders are kept. It does not run while anything else is recording. Each trigger also adds a line to autoreport-<date>.jsonl.
Heartbeat
Every 5 minutes, whether or not anything is wrong, one line goes to <day>/heartbeat-<date>.jsonl: worlds, instances, idle instances, entity and chunk counts, heap used, heap floor over 10 minutes, post-GC live set (or -1 when untrusted), non-heap, direct buffers, GC count and time, container memory, census summary, top network consumers, load levels and the worst TPS. It keeps writing while the auto report is off and stands down only while a capture is running (the capture's own 30-second cap_worlds series covers it). Use it to see whether instance count, entity count or heap climbs over a day.
Load level
Per world with players, every 5 seconds: smoothed TPS (newest 150 ticks, smoothed) and memory pressure (the larger of the container resident fraction and the 5-minute heap floor fraction). The level is the worse of the two: OK, SOFT or HARD, using the load.* keys. Escalation needs two consecutive polls. Every change is written to load-*.jsonl. ScuffedDiag takes no action on the level; other mods read it through the API.
Container memory guard
If a container memory limit is readable, it checks resident memory (RssAnon plus RssShmem) every 5 seconds against that limit, since the host kills the process on resident memory, not heap. It always publishes a container-aware heap ceiling for the soft-max guard. At 85% it clamps the soft max and writes a container-memory watch row; at 90% it forces a GC through the diagnostic interface and a native heap trim (at most every 2 minutes); at 95% after that it logs that a kill is imminent. With no readable limit it logs that it is idle.
Heap soft-max guard
At boot ScuffedDiag sets the ZGC SoftMaxHeapSize flag to 6 GB (clamped to -Xmx) so the collector reclaims garbage instead of riding to -Xmx. That value is a floor. Every 60 seconds a guard raises the target to the larger of the floor and 1.6 times the live set, never above 75% of -Xmx or the container-aware ceiling, and lowers it again when the retained set falls. There is no config key: use /scuffeddiag softmax <GB> to change the floor until the next restart, or 0 to stop adjusting. The boot log shows whether the JVM accepted the flag.
Data & storage
Everything is under mods/ScuffedDiag/, written as JSON Lines (one object per line, t in epoch milliseconds, times in UTC) by a single background writer thread, so the tick never waits on disk.
mods/ScuffedDiag/
diag-config.json web-config.json reports.jsonl LATEST.txt atlas-report.txt
2026-09-03/ one folder per UTC day
session-<UTC>/ recording started by /scuffeddiag on, report or capture
autoreport-<UTC>/ automatic low-TPS report
auto-baseline-<UTC>/ automatic baseline window
<reason>-<UTC>/ window started through the API (for example a remote caller)
watch-<date>.jsonl heartbeat-<date>.jsonl autoreport-<date>.jsonl
census-<date>.jsonl hotspots-<date>.jsonl load-<date>.jsonl locks-<date>.jsonl
net-<date>.jsonl netstream-<date>.jsonl netev-<date>.jsonl atlas-<date>.jsonl
systemtimings_live-<date>.jsonl attribution armed by the watchdog
lagreport-<UTC>.txt heaphisto-*.txt nmt-*.txt heapdump-*.hprof.gz scuffeddiag-leak-*.jfr
stalls/
auto-stall-<UTC>/ lockdump-<UTC>-<trigger>/
| Item | Retention |
|---|---|
| Day folders and everything inside them | Deleted when older than 7 days. Checked at boot and on every UTC date roll |
stalls/ | stalls.keep, stalls.maxAgeDays, stalls.maxTotalMB |
auto-baseline-* | Newest 24 |
autoreport-* | Newest 24 |
API-started windows (<reason>-*) | Newest 12 per reason label. Never swept by the automatic prunes |
session-* made by hand | Not pruned by count. They go with their day folder |
Root files (LATEST.txt, configs, reports.jsonl, atlas-report.txt) | Kept |
LATEST.txt holds the path of the newest session folder relative to the plugin folder, so pulling "the newest data" is one read. A flat mods/ScuffedDiag/ from an older version is migrated into day folders at boot.
Reading a session folder
Open SUMMARY.txt first. It lists findings (a warning sign marks anything needing attention), the report link when the viewer is running, and the file list. README.txt repeats the basics. Main files:
| File | Contents |
|---|---|
system-* | JVM flags, GC configuration, heap, threads, CPU, JDK |
worlds-*, worlds_summary-*, cap_worlds-* | World, instance, entity and chunk counts. A climbing instance count is the leaked-world signal |
alloc-*, cap_mem-* | Allocation MB/s, heap, non-heap, GC totals and JVM pauses every 30 seconds |
spikes-*, freezestacks-* | Over-budget ticks, and the call stacks that owned each freeze of 150 ms or more |
incidents-*, incident_interactions-* | Roles, counts and network state of a world at the moment of a long freeze |
jvmpauses-* | Stalls that froze every thread, including network I/O |
cap_threads-* | Per-world-thread CPU percent of one core plus host load |
cap_particles-*, cap_net-*, cap_entityupdates-*, cap_interactions-* | Particle, packet, entity-update and collision-source series |
systemtimings_live-*, resourcetimings_live-* | Deterministic per-system and per-resource tick time from Refixes. totalMs covers the window, maxMs is the worst single tick |
nettimeline-* | Per-player 1-second network timelines for the last 10 minutes |
disconnects-*, pingspikes-* | Disconnect reasons with context, and per-player ping spikes |
log_findings-* | Server log lines that matched known incident signatures |
refixes_config-* | Which Refixes optimizations were on |
report.html | The generated report page |
Reading the tick numbers: ScuffedDiag measures the interval between ticks, not work time. A healthy world paces at about 33 ms per tick, so a mean near 33 ms is good. TPS and the slow percentage (intervals of 50 ms or more) are the signals; use spark for steady work-time MSPT.
Report viewer
Each capture window mints a link to a self-contained report.html for its folder, served by a small built-in HTTP server (two worker threads, GET and HEAD only, 120 requests per minute). The link is announced when the window opens, so you can watch the page while it records; until the window closes the page shows "Still recording" and refreshes every 20 seconds.
Security model: the link is the access control. Each token is 128 random bits, there is no login and no index page, and a request path is never used to find a file. Anyone holding a link can read that bundle and download its zip, so treat links like the data inside (the bundle can name players). Bind to 127.0.0.1 behind a proxy if that trade is wrong for you, or set allowBundleDownload to false. Links expire after linkTtlDays and stop working once their folder is pruned. They survive restarts through reports.jsonl.
Setup:
- The port defaults to game port plus 2, which usually falls inside the range a hosting panel already opens. Set
portto override. - Set
publicHostto the address players use to reach the server. If it is blank, the host is detected when the first report is made (a public address on the server, else the hosting panel's address variable). When only a private address is found, links will not open from outside and/scuffeddiag websays so. EditpublicHostand run/scuffeddiag web reload. - Use
publicBaseUrlinstead when a reverse proxy or tunnel serves the viewer and the link must not carry a port.
/scuffeddiag web actions:
| Action | Effect |
|---|---|
status | Listener, port origin and link format, with a warning if the address is private |
link | Link for the newest bundle (the live session while a window runs, else LATEST.txt, else the newest folder) |
build | Rebuild report.html for the newest bundle now. Use after a window was cut short by a restart, or to give an old bundle a viewer |
list | The 10 most recent live links |
reload | Re-read web-config.json |
The report page
| Section | What it shows |
|---|---|
| Header and chips | JDK, GC, cores, -Xmx, threads, uptime. Theme toggle and "Download bundle" |
| Tiles | Worst tick, peak heap, peak allocation, JVM pauses, freezes recorded |
| Findings | The same flagged lines as SUMMARY.txt |
| Timeline | Heap, allocation rate, tick freezes (by world), GC pause time, JVM-wide pauses, world-thread CPU, host CPU, entities (with the share in idle instance worlds), worlds and chunks, outbound bandwidth by packet type, particle packets. All on one time axis, downsampled to 600 points. Heap and entity counts use the bucket mean; allocation, tick time and packet rate use the bucket maximum so a burst is not averaged away |
| Where the time went | Ranked tables: per-system tick time, per-resource save time, outbound packet types, particle effects by id, entity-update components, collision sources, and players (worst ping first) |
| Freezes | The worst freezes, each with a split of which code owned the samples and the call paths that repeated |
| Bundle | Matching server log lines and the file list |
How to read it: world-thread CPU near 100% means expensive work (optimize the owner); a stalled thread near 0% means it was blocked or starved of a core (a lock, I/O, a ZGC allocation stall, or too many threads for the box). A high queue with normal ping points to server-side backpressure, not the player's connection. A JVM-wide pause with no world freeze explains a ping spike for every player at once. A report with no named systems means Refixes was absent or too old.
Integrations
Refixes (optional)
Reached by reflection, so ScuffedDiag runs without it. A capture window enables Refixes' per-system, per-resource and diagnostic meters (queue latency, page events, path-finding, archetype churn), snapshots them every 60 seconds so a cut-short window still has data, and switches them off at the end. Do not run /systemtimings capture alongside a window; the two clear each other's counters.
Discord and other bridges
ScuffedDiag has no Discord settings and no webhook keys of its own. It exposes an API and an event feed (below). The EndlessLink chat bridge uses them for a Discord slash command and for server-log alerts, and its configuration lives with that mod. Commands arriving through a bridge are judged by the sender's own permission answer, see Permissions.
API for other mods
com.zbeve.scuffeddiag.api.DiagApi is the supported entry point (API version 3). Every parameter and return value is a plain JDK type, so a caller can reach it by reflection with no compile dependency, and it degrades to an error map when ScuffedDiag is absent. Methods are safe from any thread and never throw.
| Method | Returns |
|---|---|
apiVersion() / isReady() | API version number; whether setup has finished |
status() | Live health map: per-world TPS lines, worst freeze, heap, allocation, JVM pauses, players, watchdog and auto-capture state, capture state |
startCapture(minutes, reason, onDone) | Starts a window (1 to 30 minutes) and returns the folder and report URL. onDone receives the summary map when the window closes. An automatic baseline or stall window yields to it; another human's window does not |
stopCapture() | Ends a running window early (the onDone sink still fires) |
recentIncidents(max) | Up to 20 recent watchdog records, newest first (trigger, detail, atMs) |
setAuto(what, value) | on, off, baseline ("<durationMin> <periodMin>" or off), stall (on or off) |
addListener(id, sink) / removeListener(id) | Subscribe to events. Re-registering an id replaces the old sink |
loadLevel(world), loadLevelCode(world), serverLoadLevel(), loadLevels() | OK / SOFT / HARD (0, 1, 2; -1 unknown) for one world, the whole server, or every tracked world |
lagReport() | The /scuffeddiag lagreport text without writing a file. Contains player names and coordinates |
Events delivered to a listener, on a dedicated thread through a bounded queue that drops when full:
| Event | When | Main keys |
|---|---|---|
watchdog | A lag record was saved | trigger, detail, atMs |
capture-start | A window opened | reason, minutes, folder, systemTiming, diagMeters, reportUrl |
capture-end | A window closed | reason, minutes, folder, findings, worlds, worstFreezeMs, heap peak and GC figures, reportUrl |
Load levels are observation only: a mod can read them to shed optional work, and ScuffedDiag itself takes no gameplay action.
Overhead
- Always-on cost is small and bounded: one-second off-thread samplers, a 5-second load poll, and the census, which uses at most about
census.budgetUsof world-thread time per tick and reads a large world over several ticks. The census cost appears at the bottom oflagreport. - With recording off, the freeze stack sampler, taps and spike records are skipped. The tick monitor still records the cheap tick interval so the watchdog and TPS guard can work.
- Recording turns on the stack sampler (only walks stacks while a tick is already over budget) and four packet taps, and runs a collision probe on each populated world every 30 seconds, time-capped at 15 ms. At the default baseline schedule that is a 10% duty cycle.
- Per-system timing in Refixes adds a small allocation per system tick while armed, so it is armed only for windows and for 2 minutes after a severe stall.
- Heavy tools are on demand only:
heaphistoandheapdumppause the server,leakhuntandallocprofileadd roughly 1 to 2% while recording, and the lock dump costs one safepoint and a stack walk per thread. - Report building runs on a low-priority thread, and streaming the zip does not buffer the bundle in memory.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
spikes, disconnects or particles is empty | Recording is off by default. Run /scuffeddiag on, or open a window with capture or report |
| Command answers "You need operator status" | Grant scuffeddiag.command or OP to the player, or run it from the console |
status shows "no ticks observed in the last minute" | That world has no players. Tick monitoring only counts worlds with players |
| Report link does not open from outside | The link host is a private guess. Set publicHost (or publicBaseUrl) in web-config.json and run /scuffeddiag web reload |
| Log says the viewer could not bind | The port is in use or blocked. Set port to another port inside your allocated range, or change portOffset, then restart |
| Link shows "Still recording" forever | The window was cut short by a restart. Run /scuffeddiag web build |
| Link says "Report not found" | The link expired or the day folder was pruned (7 days) |
| No named systems in the report | Refixes is missing or too old. Install a current Refixes |
CAPPED.txt in a session folder | The session reached 120 MB and stopped writing. Run a shorter window |
nmt says NMT is not enabled | Add -XX:NativeMemoryTracking=summary to the JVM arguments. The other sections of the dump still work |
leakhunt or heaphisto fails to start | The host blocks JFR or the diagnostic MBean |
| No automatic bundles after a freeze | Check watch status, auto status and autoreport status. Windows are skipped during the cooldown, while an operator is recording, or when disk is low for the allocation profile |
diag-config.json changes ignored | A syntax error leaves the file untouched and runs on defaults. Fix it and run /scuffeddiag config reload; check with config show |
| Heap flag warning at boot | The JVM does not expose SoftMaxHeapSize (not ZGC). The rest of the plugin is unaffected |