🆙 Wired Core updates

Thanks to Migueg
This commit is contained in:
DuckieTM
2026-04-04 16:19:46 +02:00
parent 0916ff1e0b
commit e6f824aedd
5 changed files with 695 additions and 477 deletions
@@ -0,0 +1 @@
INSERT INTO emulator_settings (`key`, `value`) VALUES ('wired.tick.workers', '6');
@@ -93,6 +93,9 @@ public final class WiredEngine {
/** Track rooms that are banned from wired execution: roomId -> ban expiry timestamp */
private final ConcurrentHashMap<Integer, Long> bannedRooms;
/** Cache room+eventType+sourceItemId -> matching stacks for source-triggered timer events */
private final ConcurrentHashMap<String, List<WiredStack>> sourceStacksByTriggerKey;
/**
* Create a new wired engine.
*
@@ -112,6 +115,7 @@ public final class WiredEngine {
this.roomRecursionDepth = new ConcurrentHashMap<>();
this.eventRateLimiters = new ConcurrentHashMap<>();
this.bannedRooms = new ConcurrentHashMap<>();
this.sourceStacksByTriggerKey = new ConcurrentHashMap<>();
}
/**
@@ -132,14 +136,8 @@ public final class WiredEngine {
int roomId = room.getId();
// Check if room is banned from wired execution
if (isRoomBanned(roomId)) {
return false;
}
// Check rate limiting to prevent rapid-fire event spam (e.g., collision + chase loop)
// Soft rate limiting to prevent rapid-fire event spam without banning whole rooms
if (isRateLimited(roomId, room, event.getType())) {
// Room has been banned, all events will be dropped
return false;
}
@@ -166,6 +164,128 @@ public final class WiredEngine {
}
}
/**
* Handle a wired event when the source trigger item is already known.
* This is mainly used by timed wired triggers to avoid scanning unrelated stacks.
*
* @param event the event to handle
* @param sourceItemId the trigger item id that originated the event
* @return true if any matching stack was triggered
*/
public boolean handleEventForSourceItem(WiredEvent event, int sourceItemId) {
if (event == null || sourceItemId <= 0) {
return false;
}
Room room = event.getRoom();
if (room == null || !room.isLoaded()) {
return false;
}
int roomId = room.getId();
if (isRateLimited(roomId, room, event.getType())) {
return false;
}
int currentDepth = roomRecursionDepth.getOrDefault(roomId, 0);
if (currentDepth >= MAX_RECURSION_DEPTH) {
LOGGER.warn("Wired recursion limit reached in room {} (depth: {}). " +
"Possible infinite loop detected (source item execution). Aborting.", roomId, currentDepth);
debug(room, "RECURSION LIMIT REACHED - aborting source-item execution");
return false;
}
roomRecursionDepth.put(roomId, currentDepth + 1);
try {
return handleEventForSourceItemInternal(event, room, sourceItemId);
} finally {
int newDepth = roomRecursionDepth.getOrDefault(roomId, 1) - 1;
if (newDepth <= 0) {
roomRecursionDepth.remove(roomId);
} else {
roomRecursionDepth.put(roomId, newDepth);
}
}
}
/**
* Internal event handling optimized for a known source trigger item.
*/
private boolean handleEventForSourceItemInternal(WiredEvent event, Room room, int sourceItemId) {
List<WiredStack> stacks = getStacksForSourceItem(room, event.getType(), sourceItemId);
if (stacks.isEmpty()) {
return false;
}
debug(room, "Processing {} stacks for event type {} from source item {}", stacks.size(), event.getType(), sourceItemId);
boolean anyTriggered = false;
boolean suppressSaysOutput = false;
long triggerTime = event.getCreatedAtMs();
for (WiredStack stack : stacks) {
try {
boolean triggered = processStack(stack, event, triggerTime);
if (triggered) {
anyTriggered = true;
if ((event.getType() == WiredEvent.Type.USER_SAYS)
&& (stack.triggerItem() instanceof WiredTriggerHabboSaysKeyword)
&& ((WiredTriggerHabboSaysKeyword) stack.triggerItem()).isHideMessage()) {
suppressSaysOutput = true;
}
}
} catch (WiredLimitException limitEx) {
debug(room, "Stack execution stopped (limit): {}", limitEx.getMessage());
} catch (Exception ex) {
LOGGER.error("Error processing source wired stack in room {} for item {}: {}",
room.getId(), sourceItemId, ex.getMessage(), ex);
debug(room, "Source stack error: {}", ex.getMessage());
}
}
if (event.getType() == WiredEvent.Type.USER_SAYS) {
return suppressSaysOutput;
}
return anyTriggered;
}
/**
* Find all stacks for a specific room/event/source item combination.
* Multiple stacks can legally share the same trigger item.
*/
private List<WiredStack> getStacksForSourceItem(Room room, WiredEvent.Type eventType, int sourceItemId) {
String cacheKey = room.getId() + ":" + eventType.name() + ":" + sourceItemId;
List<WiredStack> cached = sourceStacksByTriggerKey.get(cacheKey);
if (cached != null) {
return cached;
}
List<WiredStack> allStacks = index.getStacks(room, eventType);
if (allStacks.isEmpty()) {
sourceStacksByTriggerKey.put(cacheKey, Collections.emptyList());
return Collections.emptyList();
}
List<WiredStack> matching = new ArrayList<>();
for (WiredStack stack : allStacks) {
if (stack == null || stack.triggerItem() == null) {
continue;
}
if (stack.triggerItem().getId() == sourceItemId) {
matching.add(stack);
}
}
List<WiredStack> result = matching.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(matching);
sourceStacksByTriggerKey.put(cacheKey, result);
return result;
}
/**
* Internal event handling after recursion check.
*/
@@ -234,10 +354,10 @@ public final class WiredEngine {
state.step();
debug(room, "Trigger matched: {} at item {} (conditions: {}, effects: {})",
event.getType(),
stack.triggerItem() != null ? stack.triggerItem().getId() : "null",
stack.conditions().size(),
stack.effects().size());
event.getType(),
stack.triggerItem() != null ? stack.triggerItem().getId() : "null",
stack.conditions().size(),
stack.effects().size());
// Run selectors before conditions so targets are available
List<InteractionWiredEffect> executedSelectors = Collections.emptyList();
@@ -832,10 +952,16 @@ public final class WiredEngine {
* Log a debug message if debug mode is enabled.
*/
private void debug(Room room, String format, Object... args) {
if (WiredManager.isDebugEnabled()) {
String message = String.format(format.replace("{}", "%s"), args);
LOGGER.info("[WiredEngine][Room {}] {}", room.getId(), message);
if (!WiredManager.isDebugEnabled()) {
return;
}
if (!LOGGER.isDebugEnabled()) {
return;
}
String message = String.format(format.replace("{}", "%s"), args);
LOGGER.debug("[WiredEngine][Room {}] {}", room.getId(), message);
}
/**
@@ -962,13 +1088,48 @@ public final class WiredEngine {
eventRateLimiters.keySet().removeIf(key -> key.startsWith(prefix));
}
/**
* Clear cached source-stack lookups for a specific room.
* @param roomId the room ID
*/
public void clearRoomSourceStackCache(int roomId) {
String prefix = roomId + ":";
sourceStacksByTriggerKey.keySet().removeIf(key -> key.startsWith(prefix));
}
/**
* Clear all cached source-stack lookups.
*/
public void clearAllSourceStackCache() {
sourceStacksByTriggerKey.clear();
}
/**
* Clear all execution-related caches for a specific room.
* @param roomId the room ID
*/
public void clearRoomExecutionCaches(int roomId) {
clearRoomRecursionDepth(roomId);
clearRoomRateLimiters(roomId);
clearRoomSourceStackCache(roomId);
}
/**
* Clear all execution-related caches.
*/
public void clearAllExecutionCaches() {
clearAllRecursionDepth();
eventRateLimiters.clear();
clearAllSourceStackCache();
clearUnseenCache();
}
/**
* Clear room ban for a specific room.
* Should be called when a room is unloaded.
* @param roomId the room ID
*/
public void clearRoomBan(int roomId) {
bannedRooms.remove(roomId);
// no-op
}
/**
@@ -977,60 +1138,23 @@ public final class WiredEngine {
* @return true if wired is banned in this room
*/
private boolean isRoomBanned(int roomId) {
Long banExpiry = bannedRooms.get(roomId);
if (banExpiry == null) {
return false;
}
if (System.currentTimeMillis() >= banExpiry) {
// Ban expired, remove it
bannedRooms.remove(roomId);
return false;
}
return true;
return false;
}
/**
* Ban wired execution in a room for WIRED_BAN_DURATION_MS.
* Sends alerts to all users in the room and a scripter alert to staff.
* Ban wired execution in a room.
* @param roomId the room ID
* @param room the room object (for sending alerts)
* @param room the room object
*/
private void banRoom(int roomId, Room room) {
long banExpiry = System.currentTimeMillis() + WIRED_BAN_DURATION_MS;
bannedRooms.put(roomId, banExpiry);
long banMinutes = WIRED_BAN_DURATION_MS / 60000;
// Send alert to all users in the room
String roomAlertMessage = Emulator.getTexts().getValue("wired.abuse.room.alert")
.replace("%minutes%", String.valueOf(banMinutes));
room.sendComposer(new GenericAlertComposer(roomAlertMessage).compose());
// Send scripter bubble alert to staff with room link
THashMap<String, String> keys = new THashMap<>();
keys.put("title", Emulator.getTexts().getValue("wired.abuse.staff.title"));
keys.put("message", Emulator.getTexts().getValue("wired.abuse.staff.message")
.replace("%roomname%", room.getName())
.replace("%owner%", room.getOwnerName())
.replace("%minutes%", String.valueOf(banMinutes)));
keys.put("linkUrl", "event:navigator/goto/" + roomId);
keys.put("linkTitle", Emulator.getTexts().getValue("wired.abuse.staff.link"));
Emulator.getGameEnvironment().getHabboManager().sendPacketToHabbosWithPermission(
new BubbleAlertComposer("admin.staffalert", keys).compose(),
"acc_modtool_room_info"
);
LOGGER.warn("Wired abuse detected in room {} ({}). Owner: {}. Wired banned for {} minutes.",
roomId, room.getName(), room.getOwnerName(), banMinutes);
// no-op
}
/**
* Check if an event should be rate-limited.
* If rate limit exceeded, bans the room and sends alerts.
* Uses a soft limiter only, without banning rooms.
* @param roomId the room ID
* @param room the room object (for sending alerts if banned)
* @param room the room object
* @param eventType the event type
* @return true if the event should be blocked due to rate limiting
*/
@@ -1048,8 +1172,8 @@ public final class WiredEngine {
boolean limited = tracker.isRateLimited(now);
if (limited && tracker.shouldBan(now)) {
// First time hitting limit in this suppression window - ban the room
banRoom(roomId, room);
LOGGER.warn("Soft wired rate limit in room {} for event {}. Count in current window exceeded.",
roomId, eventType);
}
return limited;
}
@@ -1060,20 +1184,19 @@ public final class WiredEngine {
private static final class EventRateTracker {
private long windowStart;
private int eventCount;
private boolean banned;
private boolean warned;
EventRateTracker(long now) {
this.windowStart = now;
this.eventCount = 1;
this.banned = false;
this.warned = false;
}
synchronized void recordEvent(long now) {
// Reset window if expired
if (now - windowStart > RATE_LIMIT_WINDOW_MS) {
windowStart = now;
eventCount = 1;
// Don't reset banned here - room ban is checked separately
warned = false;
} else {
eventCount++;
}
@@ -1083,13 +1206,9 @@ public final class WiredEngine {
return eventCount > MAX_EVENTS_PER_WINDOW;
}
/**
* Check if this is the first time we've hit the limit (to trigger ban).
* Returns true only once per suppression window.
*/
synchronized boolean shouldBan(long now) {
if (eventCount > MAX_EVENTS_PER_WINDOW && !banned) {
banned = true;
if (eventCount > MAX_EVENTS_PER_WINDOW && !warned) {
warned = true;
return true;
}
return false;
@@ -162,7 +162,7 @@ public final class WiredManager {
}
if (engine != null) {
engine.clearUnseenCache();
engine.clearAllExecutionCaches();
}
initialized = false;
@@ -216,6 +216,18 @@ public final class WiredManager {
return engine.handleEvent(event);
}
/**
* Handle a wired event using the new engine when the source trigger item is already known.
* Used by timed wired to avoid scanning unrelated stacks.
*/
private static boolean handleEventForSourceItem(WiredEvent event, HabboItem sourceItem) {
if (!isEnabled() || engine == null || event == null || sourceItem == null) {
return false;
}
return engine.handleEventForSourceItem(event, sourceItem.getId());
}
/**
* Trigger when a user walks onto furniture.
*/
@@ -365,24 +377,24 @@ public final class WiredManager {
* Trigger a timer tick.
*/
public static boolean triggerTimerTick(Room room, HabboItem timerItem) {
if (!isEnabled() || room == null) {
if (!isEnabled() || room == null || timerItem == null) {
return false;
}
WiredEvent event = WiredEvents.timerTick(room, timerItem);
return handleEvent(event);
return handleEventForSourceItem(event, timerItem);
}
/**
* Trigger a periodic timer.
*/
public static boolean triggerTimerRepeat(Room room, HabboItem timerItem) {
if (!isEnabled() || room == null) {
if (!isEnabled() || room == null || timerItem == null) {
return false;
}
WiredEvent event = WiredEvents.timerRepeat(room, timerItem);
return handleEvent(event);
return handleEventForSourceItem(event, timerItem);
}
public static boolean triggerClockCounter(Room room, HabboItem counterItem) {
@@ -391,31 +403,31 @@ public final class WiredManager {
}
WiredEvent event = WiredEvents.clockCounter(room, counterItem);
return handleEvent(event);
return handleEventForSourceItem(event, counterItem);
}
/**
* Trigger a long periodic timer.
*/
public static boolean triggerTimerRepeatLong(Room room, HabboItem timerItem) {
if (!isEnabled() || room == null) {
if (!isEnabled() || room == null || timerItem == null) {
return false;
}
WiredEvent event = WiredEvents.timerRepeatLong(room, timerItem);
return handleEvent(event);
return handleEventForSourceItem(event, timerItem);
}
/**
* Trigger a short periodic timer.
*/
public static boolean triggerTimerRepeatShort(Room room, HabboItem timerItem) {
if (!isEnabled() || room == null) {
if (!isEnabled() || room == null || timerItem == null) {
return false;
}
WiredEvent event = WiredEvents.timerRepeatShort(room, timerItem);
return handleEvent(event);
return handleEventForSourceItem(event, timerItem);
}
/**
@@ -586,11 +598,20 @@ public final class WiredManager {
* Call this when wired items are added/removed/moved.
*/
public static void invalidateRoom(Room room) {
if (stackIndex != null && room != null) {
if (room == null) {
return;
}
if (stackIndex != null) {
stackIndex.invalidateAll(room);
if (debugEnabled) {
LOGGER.info("[Wired] Cache invalidated for room {}", room.getId());
}
}
if (engine != null) {
engine.clearRoomExecutionCaches(room.getId());
}
if (debugEnabled) {
LOGGER.info("[Wired] Cache invalidated for room {}", room.getId());
}
}
@@ -601,13 +622,25 @@ public final class WiredManager {
if (stackIndex != null && room != null && tile != null) {
stackIndex.invalidate(room, tile);
}
if (engine != null && room != null) {
engine.clearRoomSourceStackCache(room.getId());
}
}
/**
* Rebuild the wired index for a room.
*/
public static void rebuildRoom(Room room) {
if (stackIndex != null && room != null) {
if (room == null) {
return;
}
if (engine != null) {
engine.clearRoomExecutionCaches(room.getId());
}
if (stackIndex != null) {
stackIndex.rebuild(room);
}
}
@@ -725,6 +758,9 @@ public final class WiredManager {
*/
public static void unregisterRoomTickables(Room room) {
WiredTickService.getInstance().unregisterRoom(room);
if (engine != null && room != null) {
engine.clearRoomExecutionCaches(room.getId());
}
}
/**
@@ -804,9 +840,9 @@ public final class WiredManager {
if (item instanceof InteractionWiredEffect && !(item instanceof WiredEffectTriggerStacks)) {
InteractionWiredEffect effect = (InteractionWiredEffect) item;
WiredEvent event = WiredEvent.builder(WiredEvent.Type.CUSTOM, room)
.actor(roomUnit)
.callStackDepth(callStackDepth)
.build();
.actor(roomUnit)
.callStackDepth(callStackDepth)
.build();
WiredContext ctx = new WiredContext(event, effect, DefaultWiredServices.getInstance(), new WiredState(100));
effect.execute(ctx);
effect.setCooldown(millis);
@@ -1066,4 +1102,3 @@ public final class WiredManager {
return false;
}
}
@@ -9,133 +9,110 @@ import java.util.Map;
import java.util.Set;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
/**
* Centralized tick service for all wired timing operations.
* <p>
* This service runs a single 50ms tick loop that processes all registered
* {@link WiredTickable} items across all rooms. This replaces the old
* per-room 500ms cycle approach and provides:
* </p>
*
* <ul>
* <li>Higher resolution timing (50ms vs 500ms)</li>
* <li>Centralized management - single thread for all rooms</li>
* <li>Proper room lifecycle handling</li>
* <li>Efficient registration/unregistration</li>
* </ul>
*
* <h3>Architecture:</h3>
* <pre>
* WiredTickService (singleton)
* └── ScheduledExecutorService (50ms tick)
* └── For each room with tickables:
* └── For each WiredTickable:
* └── onWiredTick(room, currentTime)
* </pre>
*
* <h3>Thread Safety:</h3>
* All collections are thread-safe. The tick loop catches and logs exceptions
* to prevent one bad item from crashing the entire service.
*
* @see WiredTickable
* <p>This version keeps a single global tick clock, but distributes room processing
* across multiple single-threaded shard workers. A room is always processed on the
* same shard, preserving in-room order while preventing one heavy room from delaying
* all other rooms.</p>
*/
public final class WiredTickService {
private static final Logger LOGGER = LoggerFactory.getLogger(WiredTickService.class);
/** Default tick interval in milliseconds */
public static final int DEFAULT_TICK_INTERVAL_MS = 50;
/** Minimum allowed tick interval (prevents CPU overload) */
public static final int MIN_TICK_INTERVAL_MS = 10;
/** Maximum allowed tick interval */
public static final int MAX_TICK_INTERVAL_MS = 500;
/** Singleton instance */
public static final int DEFAULT_WORKER_COUNT = Math.max(2, Math.min(8, Runtime.getRuntime().availableProcessors()));
public static final int MIN_WORKER_COUNT = 1;
public static final int MAX_WORKER_COUNT = 32;
public static final long SLOW_TICKABLE_THRESHOLD_MS = 100L;
public static final long SLOW_ROOM_THRESHOLD_MS = 50L;
public static final long SLOW_SHARD_THRESHOLD_MS = 250L;
private static volatile WiredTickService instance;
/** The configured tick interval in milliseconds */
private int tickIntervalMs = DEFAULT_TICK_INTERVAL_MS;
/** Whether debug logging is enabled */
private boolean debugEnabled = false;
/** Thread priority for the tick service */
private int threadPriority = Thread.NORM_PRIORITY + 1;
private int workerCount = DEFAULT_WORKER_COUNT;
/**
* Global tick counter - increments every tick.
* All repeaters use this to stay synchronized.
* Repeaters fire when (tickCount * tickIntervalMs) % repeatTime == 0
*/
private volatile long tickCount = 0;
/** Global logical tick counter shared by every shard. */
private final AtomicLong tickCount = new AtomicLong(0);
/** The scheduled executor for the tick loop */
private ScheduledExecutorService scheduler;
/** Schedules the global logical ticks. */
private ScheduledExecutorService coordinator;
/** The scheduled future for the tick task */
private ScheduledFuture<?> tickTask;
/** One single-thread executor per shard, preserving order inside the shard. */
private ExecutorService[] shardExecutors;
/** Highest logical tick requested for each shard. */
private AtomicLong[] shardRequestedTicks;
/** Last logical tick fully processed by each shard. */
private AtomicLong[] shardProcessedTicks;
/** Whether a shard worker loop is currently scheduled/running. */
private AtomicBoolean[] shardScheduled;
/** Map of room ID to set of registered tickables */
private final ConcurrentHashMap<Integer, Set<WiredTickable>> roomTickables;
/** Whether the service is running */
private final AtomicBoolean running;
/**
* Private constructor for singleton.
*/
private WiredTickService() {
this.roomTickables = new ConcurrentHashMap<>();
this.running = new AtomicBoolean(false);
}
/**
* Loads configuration from emulator settings.
*/
private void loadConfiguration() {
// Load tick interval
int configuredInterval = Emulator.getConfig().getInt("wired.tick.interval.ms", DEFAULT_TICK_INTERVAL_MS);
this.tickIntervalMs = Math.max(MIN_TICK_INTERVAL_MS, Math.min(MAX_TICK_INTERVAL_MS, configuredInterval));
if (configuredInterval != this.tickIntervalMs) {
LOGGER.warn("wired.tick.interval.ms value {} is out of range [{}-{}], using {}",
configuredInterval, MIN_TICK_INTERVAL_MS, MAX_TICK_INTERVAL_MS, this.tickIntervalMs);
LOGGER.warn(
"wired.tick.interval.ms value {} is out of range [{}-{}], using {}",
configuredInterval,
MIN_TICK_INTERVAL_MS,
MAX_TICK_INTERVAL_MS,
this.tickIntervalMs
);
}
// Load debug flag
this.debugEnabled = Emulator.getConfig().getBoolean("wired.tick.debug", false);
// Load thread priority
int configuredPriority = Emulator.getConfig().getInt("wired.tick.thread.priority", Thread.NORM_PRIORITY + 1);
this.threadPriority = Math.max(Thread.MIN_PRIORITY, Math.min(Thread.MAX_PRIORITY, configuredPriority));
int configuredWorkers = Emulator.getConfig().getInt("wired.tick.workers", DEFAULT_WORKER_COUNT);
this.workerCount = Math.max(MIN_WORKER_COUNT, Math.min(MAX_WORKER_COUNT, configuredWorkers));
if (configuredWorkers != this.workerCount) {
LOGGER.warn(
"wired.tick.workers value {} is out of range [{}-{}], using {}",
configuredWorkers,
MIN_WORKER_COUNT,
MAX_WORKER_COUNT,
this.workerCount
);
}
}
/**
* Gets the configured tick interval in milliseconds.
*
* @return the tick interval
*/
public int getTickIntervalMs() {
return tickIntervalMs;
}
/**
* Checks if debug logging is enabled.
*
* @return true if debug is enabled
*/
public boolean isDebugEnabled() {
return debugEnabled;
}
/**
* Gets the singleton instance.
*
* @return the WiredTickService instance
*/
public int getWorkerCount() {
return workerCount;
}
public static WiredTickService getInstance() {
if (instance == null) {
synchronized (WiredTickService.class) {
@@ -147,120 +124,135 @@ public final class WiredTickService {
return instance;
}
/**
* Starts the tick service.
* <p>
* Should be called during emulator startup after WiredManager.initialize().
* </p>
*/
public synchronized void start() {
if (running.get()) {
LOGGER.warn("WiredTickService already running");
return;
}
// Load configuration from emulator settings
loadConfiguration();
LOGGER.info("Starting WiredTickService with {}ms tick interval (debug={}, priority={})...",
tickIntervalMs, debugEnabled, threadPriority);
LOGGER.info(
"Starting WiredTickService with {}ms tick interval (workers={}, debug={}, priority={})...",
tickIntervalMs,
workerCount,
debugEnabled,
threadPriority
);
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "WiredTickService");
this.coordinator = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "WiredTickCoordinator");
t.setDaemon(true);
t.setPriority(threadPriority);
return t;
});
this.tickTask = scheduler.scheduleAtFixedRate(
this::tick,
tickIntervalMs,
tickIntervalMs,
TimeUnit.MILLISECONDS
this.shardExecutors = new ExecutorService[workerCount];
this.shardRequestedTicks = new AtomicLong[workerCount];
this.shardProcessedTicks = new AtomicLong[workerCount];
this.shardScheduled = new AtomicBoolean[workerCount];
for (int i = 0; i < workerCount; i++) {
final int shardIndex = i;
this.shardExecutors[i] = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "WiredTickShard-" + shardIndex);
t.setDaemon(true);
t.setPriority(threadPriority);
return t;
});
this.shardRequestedTicks[i] = new AtomicLong(0L);
this.shardProcessedTicks[i] = new AtomicLong(0L);
this.shardScheduled[i] = new AtomicBoolean(false);
}
this.tickCount.set(0L);
running.set(true);
this.coordinator.scheduleAtFixedRate(
() -> {
try {
dispatchTick();
} catch (Throwable t) {
LOGGER.error("WiredTickService fatal coordinator error", t);
}
},
tickIntervalMs,
tickIntervalMs,
TimeUnit.MILLISECONDS
);
running.set(true);
LOGGER.info("WiredTickService started successfully");
}
/**
* Stops the tick service.
* <p>
* Should be called during emulator shutdown.
* </p>
*/
public synchronized void stop() {
if (!running.get()) {
return;
}
LOGGER.info("Stopping WiredTickService...");
running.set(false);
if (tickTask != null) {
tickTask.cancel(false);
tickTask = null;
}
if (scheduler != null) {
scheduler.shutdown();
if (coordinator != null) {
coordinator.shutdown();
try {
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
if (!coordinator.awaitTermination(5, TimeUnit.SECONDS)) {
coordinator.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
coordinator.shutdownNow();
Thread.currentThread().interrupt();
}
scheduler = null;
coordinator = null;
}
if (shardExecutors != null) {
for (ExecutorService executor : shardExecutors) {
if (executor != null) {
executor.shutdown();
}
}
for (ExecutorService executor : shardExecutors) {
if (executor == null) {
continue;
}
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
shardExecutors = null;
shardRequestedTicks = null;
shardProcessedTicks = null;
shardScheduled = null;
roomTickables.clear();
LOGGER.info("WiredTickService stopped");
}
/**
* Checks if the service is running.
*
* @return true if running
*/
public boolean isRunning() {
return running.get();
}
/**
* Registers a tickable item with the service.
* <p>
* The item will start receiving {@link WiredTickable#onWiredTick} calls
* on the next tick cycle.
* </p>
*
* @param room the room the item is in
* @param tickable the tickable item
*/
public void register(Room room, WiredTickable tickable) {
if (room == null || tickable == null) {
return;
}
int roomId = room.getId();
Set<WiredTickable> tickables = roomTickables.computeIfAbsent(
roomId,
k -> ConcurrentHashMap.newKeySet()
);
Set<WiredTickable> tickables = roomTickables.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet());
if (tickables.add(tickable)) {
tickable.onRegistered(room, System.currentTimeMillis());
}
}
/**
* Unregisters a tickable item from the service.
*
* @param room the room the item was in
* @param tickable the tickable item
*/
public void unregister(Room room, WiredTickable tickable) {
if (room == null || tickable == null) {
return;
@@ -274,19 +266,12 @@ public final class WiredTickService {
tickable.onUnregistered(room);
}
// Clean up empty sets
if (tickables.isEmpty()) {
roomTickables.remove(roomId);
}
}
}
/**
* Unregisters a tickable by ID.
*
* @param roomId the room ID
* @param tickableId the tickable item ID
*/
public void unregister(int roomId, int tickableId) {
Set<WiredTickable> tickables = roomTickables.get(roomId);
@@ -308,14 +293,6 @@ public final class WiredTickService {
}
}
/**
* Unregisters all tickables for a room.
* <p>
* Should be called when a room is unloaded.
* </p>
*
* @param room the room
*/
public void unregisterRoom(Room room) {
if (room == null) {
return;
@@ -324,18 +301,25 @@ public final class WiredTickService {
Set<WiredTickable> tickables = roomTickables.remove(room.getId());
if (tickables != null) {
for (WiredTickable tickable : tickables) {
tickable.onUnregistered(room);
WiredTickable[] snapshot = tickables.toArray(new WiredTickable[0]);
for (WiredTickable tickable : snapshot) {
try {
if (tickable != null) {
tickable.onUnregistered(room);
}
} catch (Throwable t) {
LOGGER.error(
"Error unregistering tickable {} from room {}",
tickable != null ? tickable.getId() : -1,
room.getId(),
t
);
}
}
LOGGER.debug("Unregistered {} tickables from room {}", tickables.size(), room.getId());
LOGGER.debug("Unregistered {} tickables from room {}", snapshot.length, room.getId());
}
}
/**
* Resets all timers in a room.
*
* @param room the room
*/
public void resetRoomTimers(Room room) {
if (room == null) {
return;
@@ -344,119 +328,198 @@ public final class WiredTickService {
Set<WiredTickable> tickables = roomTickables.get(room.getId());
if (tickables != null) {
for (WiredTickable tickable : tickables) {
WiredTickable[] snapshot = tickables.toArray(new WiredTickable[0]);
for (WiredTickable tickable : snapshot) {
try {
tickable.resetTimer();
} catch (Exception e) {
LOGGER.error("Error resetting timer for tickable {} in room {}",
tickable.getId(), room.getId(), e);
if (tickable != null) {
tickable.resetTimer();
}
} catch (Throwable e) {
LOGGER.error(
"Error resetting timer for tickable {} in room {}",
tickable != null ? tickable.getId() : -1,
room.getId(),
e
);
}
}
}
}
/**
* Gets the count of registered tickables for a room.
*
* @param roomId the room ID
* @return the count
*/
public int getTickableCount(int roomId) {
Set<WiredTickable> tickables = roomTickables.get(roomId);
return tickables != null ? tickables.size() : 0;
}
/**
* Gets the total count of registered tickables across all rooms.
*
* @return the total count
*/
public int getTotalTickableCount() {
return roomTickables.values().stream()
.mapToInt(Set::size)
.sum();
return roomTickables.values().stream().mapToInt(Set::size).sum();
}
/**
* Gets the count of rooms with registered tickables.
*
* @return the room count
*/
public int getActiveRoomCount() {
return roomTickables.size();
}
/**
* The main tick loop.
* <p>
* Called at the configured interval by the scheduler. Processes all registered tickables
* across all rooms.
* </p>
*/
private void tick() {
public long getTickCount() {
return tickCount.get();
}
private void dispatchTick() {
if (!running.get() || Emulator.isShuttingDown) {
return;
}
// Increment global tick counter
tickCount++;
long currentTick = tickCount.incrementAndGet();
long startTime = System.currentTimeMillis();
int tickablesProcessed = 0;
for (int shardIndex = 0; shardIndex < workerCount; shardIndex++) {
shardRequestedTicks[shardIndex].set(currentTick);
scheduleShardIfNeeded(shardIndex);
}
}
private void scheduleShardIfNeeded(int shardIndex) {
if (!running.get() || shardExecutors == null) {
return;
}
if (shardScheduled[shardIndex].compareAndSet(false, true)) {
shardExecutors[shardIndex].execute(() -> runShardLoop(shardIndex));
}
}
private void runShardLoop(int shardIndex) {
try {
while (running.get() && !Emulator.isShuttingDown) {
long nextTick = shardProcessedTicks[shardIndex].get() + 1L;
long requestedTick = shardRequestedTicks[shardIndex].get();
if (nextTick > requestedTick) {
break;
}
processShardTick(shardIndex, nextTick);
shardProcessedTicks[shardIndex].set(nextTick);
}
} catch (Throwable t) {
LOGGER.error("Fatal error in WiredTick shard {}", shardIndex, t);
} finally {
shardScheduled[shardIndex].set(false);
if (running.get() && shardProcessedTicks[shardIndex].get() < shardRequestedTicks[shardIndex].get()) {
scheduleShardIfNeeded(shardIndex);
}
}
}
private void processShardTick(int shardIndex, long currentTick) {
long shardStart = System.currentTimeMillis();
int processedTickables = 0;
int processedRooms = 0;
for (Map.Entry<Integer, Set<WiredTickable>> entry : roomTickables.entrySet()) {
int roomId = entry.getKey();
Set<WiredTickable> tickables = entry.getValue();
if (tickables.isEmpty()) {
if (getShardIndex(roomId) != shardIndex) {
continue;
}
Set<WiredTickable> tickables = entry.getValue();
if (tickables == null || tickables.isEmpty()) {
continue;
}
// Get the room - skip if not loaded
Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId);
if (room == null || !room.isLoaded()) {
continue;
}
// Skip if room is empty (optimization)
if (room.getCurrentHabbos().isEmpty() && room.getCurrentBots().isEmpty()) {
continue;
}
// Process each tickable
for (WiredTickable tickable : tickables) {
long roomStart = System.currentTimeMillis();
WiredTickable[] snapshot = tickables.toArray(new WiredTickable[0]);
if (snapshot.length == 0) {
continue;
}
processedRooms++;
for (WiredTickable tickable : snapshot) {
long tickableStart = System.currentTimeMillis();
if (tickable == null) {
continue;
}
try {
// Verify item still belongs to this room
if (tickable.getRoomId() != roomId) {
// Item moved to another room, unregister it
tickables.remove(tickable);
unregister(roomId, tickable.getId());
continue;
}
// Pass global tick count - all tickables see the same counter
// This keeps repeaters with the same interval perfectly synchronized
tickable.onWiredTick(room, tickCount, tickIntervalMs);
tickablesProcessed++;
} catch (Exception e) {
LOGGER.error("Error in wired tick for tickable {} in room {}: {}",
tickable.getId(), roomId, e.getMessage(), e);
tickable.onWiredTick(room, currentTick, tickIntervalMs);
processedTickables++;
long tickableDuration = System.currentTimeMillis() - tickableStart;
if (tickableDuration > SLOW_TICKABLE_THRESHOLD_MS) {
LOGGER.warn(
"Slow wired tickable: shard={}, room={}, tick={}, tickableId={}, class={}, took={}ms",
shardIndex,
roomId,
currentTick,
tickable.getId(),
tickable.getClass().getName(),
tickableDuration
);
}
} catch (Throwable t) {
long tickableDuration = System.currentTimeMillis() - tickableStart;
LOGGER.error(
"Error in wired tick for tickable {} in room {} after {}ms",
tickable.getId(),
roomId,
tickableDuration,
t
);
}
}
long roomDuration = System.currentTimeMillis() - roomStart;
if (roomDuration > SLOW_ROOM_THRESHOLD_MS) {
LOGGER.warn(
"Slow wired room tick: shard={}, room={}, tick={}, tickables={}, took={}ms",
shardIndex,
roomId,
currentTick,
snapshot.length,
roomDuration
);
}
}
// Debug logging if enabled
if (debugEnabled && tickablesProcessed > 0) {
LOGGER.debug("Wired tick #{} completed: {} tickables processed in {}ms",
tickCount, tickablesProcessed, System.currentTimeMillis() - startTime);
long shardDuration = System.currentTimeMillis() - shardStart;
if (shardDuration > SLOW_SHARD_THRESHOLD_MS) {
LOGGER.warn(
"Slow wired shard tick: shard={}, tick={}, rooms={}, tickables={}, took={}ms",
shardIndex,
currentTick,
processedRooms,
processedTickables,
shardDuration
);
}
if (debugEnabled && processedTickables > 0) {
LOGGER.debug(
"Wired shard tick completed: shard={}, tick={}, rooms={}, tickables={}, took={}ms",
shardIndex,
currentTick,
processedRooms,
processedTickables,
shardDuration
);
}
}
/**
* Gets the current global tick count.
*
* @return the tick count
*/
public long getTickCount() {
return tickCount;
private int getShardIndex(int roomId) {
return Math.floorMod(roomId, workerCount);
}
}