add passive swordmasters timing probe
Browse files
tools/swordmasters_timing_probe.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
Passive SwordMasters.io timing probe.
|
| 3 |
+
|
| 4 |
+
It reads the local Colyseus room state and incoming damage messages only.
|
| 5 |
+
No game actions or network messages are sent.
|
| 6 |
+
*/
|
| 7 |
+
(() => {
|
| 8 |
+
"use strict";
|
| 9 |
+
|
| 10 |
+
const VERSION = "0.1.0";
|
| 11 |
+
const DAMAGE_MESSAGE = "Server:EnemyController:getDamage";
|
| 12 |
+
const POLL_MS = 10;
|
| 13 |
+
|
| 14 |
+
if (window.SM_TIMING_PROBE && typeof window.SM_TIMING_PROBE.stop === "function") {
|
| 15 |
+
window.SM_TIMING_PROBE.stop({ quiet: true });
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const state = {
|
| 19 |
+
startedAt: new Date().toISOString(),
|
| 20 |
+
room: null,
|
| 21 |
+
roomSource: "",
|
| 22 |
+
enemies: new Map(),
|
| 23 |
+
deaths: [],
|
| 24 |
+
respawns: [],
|
| 25 |
+
damagePackets: [],
|
| 26 |
+
hookErrors: [],
|
| 27 |
+
pollTimer: 0,
|
| 28 |
+
dispatchWrapper: null,
|
| 29 |
+
originalDispatchMessage: null,
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
function finite(value) {
|
| 33 |
+
const number = Number(value);
|
| 34 |
+
return Number.isFinite(number) ? number : null;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function median(values) {
|
| 38 |
+
const sorted = values.filter(Number.isFinite).slice().sort((a, b) => a - b);
|
| 39 |
+
if (!sorted.length) return null;
|
| 40 |
+
const middle = Math.floor(sorted.length / 2);
|
| 41 |
+
return sorted.length % 2
|
| 42 |
+
? sorted[middle]
|
| 43 |
+
: (sorted[middle - 1] + sorted[middle]) / 2;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function mapEntries(mapLike) {
|
| 47 |
+
if (!mapLike) return [];
|
| 48 |
+
if (typeof mapLike.entries === "function") return Array.from(mapLike.entries());
|
| 49 |
+
const rows = [];
|
| 50 |
+
if (typeof mapLike.forEach === "function") {
|
| 51 |
+
mapLike.forEach((value, key) => rows.push([key, value]));
|
| 52 |
+
return rows;
|
| 53 |
+
}
|
| 54 |
+
return Object.entries(mapLike);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function readMapValue(mapLike, key) {
|
| 58 |
+
if (!mapLike) return null;
|
| 59 |
+
if (typeof mapLike.get === "function") {
|
| 60 |
+
const value = mapLike.get(key);
|
| 61 |
+
if (value) return value;
|
| 62 |
+
}
|
| 63 |
+
if (mapLike[key]) return mapLike[key];
|
| 64 |
+
for (const [itemKey, value] of mapEntries(mapLike)) {
|
| 65 |
+
if (String(itemKey) === String(key)) return value;
|
| 66 |
+
}
|
| 67 |
+
return null;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function readField(value, names) {
|
| 71 |
+
if (!value) return null;
|
| 72 |
+
for (const name of names) {
|
| 73 |
+
try {
|
| 74 |
+
if (value[name] != null) return value[name];
|
| 75 |
+
} catch (_) {
|
| 76 |
+
// Try the next known schema field name.
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
try {
|
| 80 |
+
const json = typeof value.toJSON === "function" ? value.toJSON() : null;
|
| 81 |
+
if (json) {
|
| 82 |
+
for (const name of names) {
|
| 83 |
+
if (json[name] != null) return json[name];
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
} catch (_) {
|
| 87 |
+
// Detached Schema objects can throw while serializing.
|
| 88 |
+
}
|
| 89 |
+
return null;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function enemySnapshot(enemyId, enemy) {
|
| 93 |
+
return {
|
| 94 |
+
enemyId: String(enemyId),
|
| 95 |
+
enemyType: finite(readField(enemy, ["type", "enemyType", "enemyTypeId", "enemyTypeIndex"])),
|
| 96 |
+
hp: finite(readField(enemy, ["health", "hp", "currentHp", "currentHP"])),
|
| 97 |
+
maxHp: finite(readField(enemy, ["maxHealth", "maxHp", "maxHP"])),
|
| 98 |
+
};
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function registerRoom(room, source) {
|
| 102 |
+
if (!room || !room.state) return;
|
| 103 |
+
state.room = room;
|
| 104 |
+
state.roomSource = source;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function findRoom() {
|
| 108 |
+
const candidates = [
|
| 109 |
+
() => window.pc && window.pc.app && window.pc.app.networkManager && window.pc.app.networkManager.room,
|
| 110 |
+
() => window.pc && window.pc.Application && window.pc.Application.getApplication &&
|
| 111 |
+
window.pc.Application.getApplication().networkManager &&
|
| 112 |
+
window.pc.Application.getApplication().networkManager.room,
|
| 113 |
+
() => window.app && window.app.networkManager && window.app.networkManager.room,
|
| 114 |
+
];
|
| 115 |
+
for (const build of candidates) {
|
| 116 |
+
try {
|
| 117 |
+
const room = build();
|
| 118 |
+
if (room && room.state) return room;
|
| 119 |
+
} catch (_) {
|
| 120 |
+
// Keep trying fallback paths.
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
for (const key of Object.keys(window)) {
|
| 124 |
+
let value;
|
| 125 |
+
try {
|
| 126 |
+
value = window[key];
|
| 127 |
+
} catch (_) {
|
| 128 |
+
continue;
|
| 129 |
+
}
|
| 130 |
+
if (value && value.room && value.room.state) return value.room;
|
| 131 |
+
if (value && value.networkManager && value.networkManager.room && value.networkManager.room.state) {
|
| 132 |
+
return value.networkManager.room;
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
return null;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
function markDeath(tracker, atMs, reason) {
|
| 139 |
+
if (!tracker || tracker.deathAtMs != null) return;
|
| 140 |
+
tracker.deathAtMs = atMs;
|
| 141 |
+
tracker.deathReason = reason;
|
| 142 |
+
state.deaths.push({
|
| 143 |
+
enemyId: tracker.enemyId,
|
| 144 |
+
enemyType: tracker.enemyType,
|
| 145 |
+
atMs,
|
| 146 |
+
reason,
|
| 147 |
+
});
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
function markRespawn(tracker, snapshot, atMs, reason) {
|
| 151 |
+
if (!tracker || tracker.deathAtMs == null) return;
|
| 152 |
+
const respawnMs = atMs - tracker.deathAtMs;
|
| 153 |
+
if (respawnMs >= 100) {
|
| 154 |
+
state.respawns.push({
|
| 155 |
+
enemyId: tracker.enemyId,
|
| 156 |
+
enemyType: snapshot.enemyType == null ? tracker.enemyType : snapshot.enemyType,
|
| 157 |
+
respawnMs,
|
| 158 |
+
deathAtMs: tracker.deathAtMs,
|
| 159 |
+
respawnAtMs: atMs,
|
| 160 |
+
deathReason: tracker.deathReason,
|
| 161 |
+
respawnReason: reason,
|
| 162 |
+
});
|
| 163 |
+
}
|
| 164 |
+
tracker.deathAtMs = null;
|
| 165 |
+
tracker.deathReason = "";
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
function observeDamage(room, message) {
|
| 169 |
+
registerRoom(room, "colyseus-dispatch");
|
| 170 |
+
const atMs = Date.now();
|
| 171 |
+
const enemyId = String(message && message.enemyId);
|
| 172 |
+
const tracker = state.enemies.get(enemyId);
|
| 173 |
+
const damageAmount = finite(message && message.damageAmount);
|
| 174 |
+
const damagers = Array.isArray(message && message.damagers)
|
| 175 |
+
? message.damagers.length
|
| 176 |
+
: Array.from((message && message.damagers) || []).length;
|
| 177 |
+
|
| 178 |
+
state.damagePackets.push({
|
| 179 |
+
atMs,
|
| 180 |
+
enemyId,
|
| 181 |
+
enemyType: tracker ? tracker.enemyType : null,
|
| 182 |
+
damageAmount,
|
| 183 |
+
damagers,
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
if (tracker && tracker.hp != null && damageAmount != null && damageAmount >= tracker.hp) {
|
| 187 |
+
markDeath(tracker, atMs, "lethal-damage-packet");
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
function pollRoomState() {
|
| 192 |
+
if (!state.room) registerRoom(findRoom(), "window-scan");
|
| 193 |
+
const enemies = state.room && state.room.state && state.room.state.enemies;
|
| 194 |
+
if (!enemies) return;
|
| 195 |
+
|
| 196 |
+
const atMs = Date.now();
|
| 197 |
+
const visible = new Set();
|
| 198 |
+
for (const [enemyId, enemy] of mapEntries(enemies)) {
|
| 199 |
+
const snapshot = enemySnapshot(enemyId, enemy);
|
| 200 |
+
visible.add(snapshot.enemyId);
|
| 201 |
+
let tracker = state.enemies.get(snapshot.enemyId);
|
| 202 |
+
if (!tracker) {
|
| 203 |
+
tracker = {
|
| 204 |
+
...snapshot,
|
| 205 |
+
present: true,
|
| 206 |
+
deathAtMs: null,
|
| 207 |
+
deathReason: "",
|
| 208 |
+
};
|
| 209 |
+
state.enemies.set(snapshot.enemyId, tracker);
|
| 210 |
+
continue;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
const wasPresent = tracker.present;
|
| 214 |
+
const previousHp = tracker.hp;
|
| 215 |
+
if (previousHp != null && previousHp > 0 && snapshot.hp != null && snapshot.hp <= 0) {
|
| 216 |
+
markDeath(tracker, atMs, "hp-zero");
|
| 217 |
+
}
|
| 218 |
+
if (
|
| 219 |
+
snapshot.hp != null &&
|
| 220 |
+
snapshot.hp > 0 &&
|
| 221 |
+
(
|
| 222 |
+
!wasPresent ||
|
| 223 |
+
(previousHp != null && snapshot.hp > previousHp) ||
|
| 224 |
+
(previousHp != null && previousHp <= 0)
|
| 225 |
+
)
|
| 226 |
+
) {
|
| 227 |
+
markRespawn(tracker, snapshot, atMs, !wasPresent ? "enemy-readded" : "hp-reset");
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
Object.assign(tracker, snapshot, { present: true });
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
for (const tracker of state.enemies.values()) {
|
| 234 |
+
if (tracker.present && !visible.has(tracker.enemyId)) {
|
| 235 |
+
tracker.present = false;
|
| 236 |
+
markDeath(tracker, atMs, "enemy-removed");
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
function inventorySummary() {
|
| 242 |
+
const room = state.room;
|
| 243 |
+
const inventory = room && room.state && readMapValue(room.state.playerInventories, room.sessionId);
|
| 244 |
+
const attackSpeed = finite(readField(inventory, ["attackSpeed"]));
|
| 245 |
+
return {
|
| 246 |
+
sessionId: room && room.sessionId || "",
|
| 247 |
+
attackSpeed,
|
| 248 |
+
attackIntervalMs: attackSpeed && attackSpeed > 0 ? 1000 / attackSpeed : null,
|
| 249 |
+
};
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
function packetRows() {
|
| 253 |
+
const byEnemy = new Map();
|
| 254 |
+
for (const packet of state.damagePackets) {
|
| 255 |
+
const key = `${packet.enemyType}:${packet.enemyId}`;
|
| 256 |
+
if (!byEnemy.has(key)) byEnemy.set(key, []);
|
| 257 |
+
byEnemy.get(key).push(packet);
|
| 258 |
+
}
|
| 259 |
+
const byType = new Map();
|
| 260 |
+
for (const packets of byEnemy.values()) {
|
| 261 |
+
packets.sort((a, b) => a.atMs - b.atMs);
|
| 262 |
+
for (let index = 1; index < packets.length; index += 1) {
|
| 263 |
+
const interval = packets[index].atMs - packets[index - 1].atMs;
|
| 264 |
+
if (interval <= 0 || interval > 900) continue;
|
| 265 |
+
const type = packets[index].enemyType;
|
| 266 |
+
if (!byType.has(type)) byType.set(type, []);
|
| 267 |
+
byType.get(type).push(interval);
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
return Array.from(byType, ([enemyType, intervals]) => ({
|
| 271 |
+
enemyType,
|
| 272 |
+
shortPacketIntervals: intervals.length,
|
| 273 |
+
shortPacketIntervalMedianMs: median(intervals),
|
| 274 |
+
})).sort((a, b) => Number(a.enemyType) - Number(b.enemyType));
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
function respawnRows() {
|
| 278 |
+
const byType = new Map();
|
| 279 |
+
for (const row of state.respawns) {
|
| 280 |
+
if (!byType.has(row.enemyType)) byType.set(row.enemyType, []);
|
| 281 |
+
byType.get(row.enemyType).push(row.respawnMs);
|
| 282 |
+
}
|
| 283 |
+
return Array.from(byType, ([enemyType, values]) => ({
|
| 284 |
+
enemyType,
|
| 285 |
+
respawns: values.length,
|
| 286 |
+
respawnMedianMs: median(values),
|
| 287 |
+
respawnMinMs: Math.min(...values),
|
| 288 |
+
respawnMaxMs: Math.max(...values),
|
| 289 |
+
likelyWholeSeconds: Math.round(median(values) / 1000),
|
| 290 |
+
})).sort((a, b) => Number(a.enemyType) - Number(b.enemyType));
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
function summary() {
|
| 294 |
+
pollRoomState();
|
| 295 |
+
const result = {
|
| 296 |
+
version: VERSION,
|
| 297 |
+
startedAt: state.startedAt,
|
| 298 |
+
roomFound: !!state.room,
|
| 299 |
+
roomSource: state.roomSource,
|
| 300 |
+
inventory: inventorySummary(),
|
| 301 |
+
enemiesTracked: state.enemies.size,
|
| 302 |
+
damagePackets: state.damagePackets.length,
|
| 303 |
+
deaths: state.deaths.length,
|
| 304 |
+
respawns: state.respawns.length,
|
| 305 |
+
attackValidation: packetRows(),
|
| 306 |
+
respawnTimings: respawnRows(),
|
| 307 |
+
recentRespawns: state.respawns.slice(-12),
|
| 308 |
+
hookErrors: state.hookErrors.slice(),
|
| 309 |
+
};
|
| 310 |
+
console.log("Inventory attack speed is the exact client cooldown input. Respawn rows use passive room-state reset timings.");
|
| 311 |
+
console.table(result.attackValidation);
|
| 312 |
+
console.table(result.respawnTimings);
|
| 313 |
+
console.table(result.recentRespawns);
|
| 314 |
+
return result;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
function reset() {
|
| 318 |
+
state.enemies.clear();
|
| 319 |
+
state.deaths.length = 0;
|
| 320 |
+
state.respawns.length = 0;
|
| 321 |
+
state.damagePackets.length = 0;
|
| 322 |
+
pollRoomState();
|
| 323 |
+
console.log("SM timing probe reset.");
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
function stop(options = {}) {
|
| 327 |
+
if (state.pollTimer) window.clearInterval(state.pollTimer);
|
| 328 |
+
state.pollTimer = 0;
|
| 329 |
+
const roomProto = window.Colyseus && window.Colyseus.Room && window.Colyseus.Room.prototype;
|
| 330 |
+
if (roomProto && roomProto.dispatchMessage === state.dispatchWrapper && state.originalDispatchMessage) {
|
| 331 |
+
roomProto.dispatchMessage = state.originalDispatchMessage;
|
| 332 |
+
}
|
| 333 |
+
if (!options.quiet) console.log("SM timing probe stopped.");
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
function install() {
|
| 337 |
+
const roomProto = window.Colyseus && window.Colyseus.Room && window.Colyseus.Room.prototype;
|
| 338 |
+
if (roomProto && typeof roomProto.dispatchMessage === "function") {
|
| 339 |
+
state.originalDispatchMessage = roomProto.dispatchMessage;
|
| 340 |
+
state.dispatchWrapper = function timingProbeDispatchMessage(type, message) {
|
| 341 |
+
try {
|
| 342 |
+
registerRoom(this, "colyseus-dispatch");
|
| 343 |
+
if (String(type) === DAMAGE_MESSAGE) observeDamage(this, message);
|
| 344 |
+
} catch (error) {
|
| 345 |
+
state.hookErrors.push(String(error && error.message || error));
|
| 346 |
+
}
|
| 347 |
+
return state.originalDispatchMessage.apply(this, arguments);
|
| 348 |
+
};
|
| 349 |
+
roomProto.dispatchMessage = state.dispatchWrapper;
|
| 350 |
+
}
|
| 351 |
+
registerRoom(findRoom(), "window-scan");
|
| 352 |
+
state.pollTimer = window.setInterval(pollRoomState, POLL_MS);
|
| 353 |
+
pollRoomState();
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
window.SM_TIMING_PROBE = {
|
| 357 |
+
version: VERSION,
|
| 358 |
+
summary,
|
| 359 |
+
reset,
|
| 360 |
+
stop,
|
| 361 |
+
state,
|
| 362 |
+
};
|
| 363 |
+
|
| 364 |
+
install();
|
| 365 |
+
console.log("SM timing probe running. Stay on one mob for several kills, then run SM_TIMING_PROBE.summary().");
|
| 366 |
+
})();
|