Skip to content
9 changes: 8 additions & 1 deletion Pengu Loader/plugins/ROSE-ChromaWheel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1809,8 +1809,15 @@
return false;
}

// Carousel items: rely on offset 2 (center/current slot)
// Carousel items: rely on selection class (new client) or offset 2 (center/current slot)
if (skinItem.classList.contains("skin-selection-item")) {
if (
skinItem.classList.contains("skin-selection-item-selected") ||
skinItem.classList.contains("selected") ||
skinItem.getAttribute("aria-selected") === "true"
) {
return true;
}
const offset = getSkinOffset(skinItem);
if (offset === 2) {
return true;
Expand Down
15 changes: 11 additions & 4 deletions Pengu Loader/plugins/ROSE-SkinMonitor/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -422,18 +422,25 @@ function setupBridgeSocket() {
return;
}

// Reset skin state when entering Lobby phase (so same skin in next game triggers detection)
// Reset skin state when entering Lobby/ChampSelect phase or when champion locked
if (data && data.type === "champion-locked") {
lastLoggedSkin = null;
window.dispatchEvent(
new CustomEvent("rose-custom-wheel-champion-locked", { detail: data })
);
reportSkinIfChanged();
return;
}

if (data && data.type === "phase-change" && data.phase === "Lobby") {
if (data && data.type === "phase-change") {
lastLoggedSkin = null;
console.log(`${LOG_PREFIX} Reset skin state for new game (Lobby phase)`);
window.dispatchEvent(new CustomEvent("rose-custom-wheel-reset"));
if (data.phase === "Lobby") {
console.log(`${LOG_PREFIX} Reset skin state for new game (Lobby phase)`);
window.dispatchEvent(new CustomEvent("rose-custom-wheel-reset"));
} else if (data.phase === "ChampSelect") {
console.log(`${LOG_PREFIX} Reset skin state for new ChampSelect`);
setTimeout(reportSkinIfChanged, 100);
}
return;
}

Expand Down
3 changes: 2 additions & 1 deletion lcu/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from .client import LCU
from .lcu_connection import LCUConnection
from .lcu_api import LCUAPI
from .lockfile import Lockfile, find_lockfile, parse_lockfile, SWIFTPLAY_MODES, SWIFTPLAY_QUEUE_ID
from .lockfile import Lockfile, find_lockfile, parse_lockfile, SWIFTPLAY_MODES, SWIFTPLAY_QUEUE_ID, SWIFTPLAY_QUEUE_IDS

__all__ = [
'LCU',
Expand All @@ -19,5 +19,6 @@
'parse_lockfile',
'SWIFTPLAY_MODES',
'SWIFTPLAY_QUEUE_ID',
'SWIFTPLAY_QUEUE_IDS',
]

5 changes: 3 additions & 2 deletions lcu/core/lockfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@

log = get_logger()

SWIFTPLAY_MODES = {"SWIFTPLAY", "BRAWL"}
SWIFTPLAY_QUEUE_ID = 480
SWIFTPLAY_MODES = {"SWIFTPLAY", "BRAWL", "QUICKPLAY"}
SWIFTPLAY_QUEUE_IDS = {480, 490} # 480: Swiftplay/Brawl, 490: Quickplay (Normal)
SWIFTPLAY_QUEUE_ID = 490 # Default Quickplay queue ID


@dataclass
Expand Down
40 changes: 18 additions & 22 deletions lcu/features/lcu_swiftplay.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from config import LCU_API_TIMEOUT_S
from utils.core.logging import get_logger

from ..core.lockfile import SWIFTPLAY_MODES, SWIFTPLAY_QUEUE_ID
from ..core.lockfile import SWIFTPLAY_MODES, SWIFTPLAY_QUEUE_ID, SWIFTPLAY_QUEUE_IDS

log = get_logger()

Expand Down Expand Up @@ -66,12 +66,12 @@ def _is_swiftplay_lobby_data(self, data: dict) -> bool:
log.debug("Found Swiftplay-like game mode in lobby data")
return True

# Check for Swiftplay queue ID (480)
# Check for Swiftplay / Quickplay queue ID (480 / 490)
if "queueId" in data:
queue_id = data.get("queueId")
log.debug(f"Found queue ID: {queue_id}")
if queue_id == SWIFTPLAY_QUEUE_ID:
log.debug("Queue ID 480 indicates Swiftplay mode")
if queue_id in SWIFTPLAY_QUEUE_IDS or queue_id == SWIFTPLAY_QUEUE_ID:
log.debug(f"Queue ID {queue_id} indicates Swiftplay / Quickplay mode")
return True

# If we're already detected as Swiftplay mode, any lobby data is likely Swiftplay
Expand Down Expand Up @@ -240,15 +240,10 @@ def _extract_dual_champion_selection_from_data(self, data: dict) -> Optional[dic
# Check localMember for primary and secondary champions
local_member = data.get("localMember")
if local_member and isinstance(local_member, dict):
log.debug("Checking localMember for champion selections...")

# Check for primaryChampionId and secondaryChampionId
primary_champion_id = local_member.get("primaryChampionId")
secondary_champion_id = local_member.get("secondaryChampionId")

log.debug(f"Primary champion ID: {primary_champion_id}")
log.debug(f"Secondary champion ID: {secondary_champion_id}")

# Add primary champion if exists
if primary_champion_id and primary_champion_id > 0:
primary_skin_id = 0
Expand All @@ -273,7 +268,6 @@ def _extract_dual_champion_selection_from_data(self, data: dict) -> Optional[dic
"spell2": primary_spell2
}
champions.append(champion_data)
log.info(f"Found PRIMARY champion: ID {primary_champion_id}, Skin {primary_skin_id}, Position {primary_position}")

# Add secondary champion if exists
if secondary_champion_id and secondary_champion_id > 0:
Expand All @@ -299,13 +293,11 @@ def _extract_dual_champion_selection_from_data(self, data: dict) -> Optional[dic
"spell2": secondary_spell2
}
champions.append(champion_data)
log.info(f"Found SECONDARY champion: ID {secondary_champion_id}, Skin {secondary_skin_id}, Position {secondary_position}")

# Fallback: Check playerSlots if primary/secondary not found
if not champions:
player_slots = local_member.get("playerSlots", [])
if isinstance(player_slots, list):
log.debug(f"Fallback: Checking {len(player_slots)} player slots in localMember")
for i, slot in enumerate(player_slots):
if isinstance(slot, dict):
champion_id = slot.get("championId")
Expand All @@ -314,8 +306,6 @@ def _extract_dual_champion_selection_from_data(self, data: dict) -> Optional[dic
spell1 = slot.get("spell1", 0)
spell2 = slot.get("spell2", 0)

log.debug(f"Player slot {i}: championId={champion_id}, skinId={skin_id}, position={position}")

if champion_id and champion_id > 0:
champion_data = {
"championId": champion_id,
Expand All @@ -325,22 +315,28 @@ def _extract_dual_champion_selection_from_data(self, data: dict) -> Optional[dic
"spell2": spell2
}
champions.append(champion_data)
log.info(f"Found champion in slot {i}: ID {champion_id}, Skin {skin_id}, Position {position}")
else:
log.debug("No localMember found in lobby data")

# Signature of current slots to avoid repetitive logging on each tick
current_sig = tuple(
(c.get("championId"), c.get("skinId"), c.get("position"))
for c in champions
) if champions else None

changed = (current_sig != getattr(self, "_last_dual_slots_sig", None))
if changed:
self._last_dual_slots_sig = current_sig
if champions:
log.debug(f"Extracted {len(champions)} local champions from Swiftplay lobby data: {champions}")
else:
log.debug("No local champions found in lobby data")

if champions:
log.info(f"Extracted {len(champions)} local champions from Swiftplay lobby data")
for i, champ in enumerate(champions):
log.info(f" Champion {i+1}: ID {champ['championId']}, Skin {champ['skinId']}, Position {champ['position']}")

return {
"champions": champions,
"champion_1": champions[0] if len(champions) > 0 else None,
"champion_2": champions[1] if len(champions) > 1 else None
}

log.warning("No local champions found in lobby data")
return None

except Exception as e:
Expand Down
9 changes: 9 additions & 0 deletions main/core/initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ def initialize_core_components(args, injection_threshold: Optional[float] = None

log.info("Initializing shared state...")
state = SharedState()
if lcu.ok:
try:
raw_lang = lcu.client_language
if raw_lang:
lang_code = raw_lang.split('_')[0] if '_' in raw_lang else raw_lang
state.current_language = lang_code
log.info(f"Initial language detected: {lang_code} (from {raw_lang})")
except Exception as e:
log.debug(f"Failed to detect initial language: {e}")
log.info("Shared state initialized")
except Exception as e:
log.error("=" * 80)
Expand Down
7 changes: 7 additions & 0 deletions main/setup/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ class COORD(ctypes.Structure):
# Set buffer size for both stdout and stderr
ctypes.windll.kernel32.SetConsoleScreenBufferSize(stdout_handle, new_size)
ctypes.windll.kernel32.SetConsoleScreenBufferSize(stderr_handle, new_size)

# Enable Virtual Terminal Processing for ANSI colors (cmd.exe / conhost)
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
for h in (stdout_handle, stderr_handle):
mode = ctypes.c_ulong()
if ctypes.windll.kernel32.GetConsoleMode(h, ctypes.byref(mode)):
ctypes.windll.kernel32.SetConsoleMode(h, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING)
except (OSError, AttributeError):
# Failed to increase buffer size - not critical, will rely on queue-based logging
pass
Expand Down
12 changes: 10 additions & 2 deletions pengu/processing/skin_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,16 @@ def load_mapping(self) -> bool:
"""
language = getattr(self.shared_state, "current_language", None)
if not language:
log.warning("[SkinMonitor] No language detected; cannot load mapping")
return False
# Fallback: attempt to read from LCU if available
lcu = getattr(self.shared_state, "lcu", None)
if lcu and hasattr(lcu, "client_language"):
raw_lang = lcu.client_language
if raw_lang:
language = raw_lang.split('_')[0] if '_' in raw_lang else raw_lang
self.shared_state.current_language = language
if not language:
log.warning("[SkinMonitor] No language detected; cannot load mapping")
return False

mapping_path = (
get_user_data_dir()
Expand Down
26 changes: 26 additions & 0 deletions pengu/processing/skin_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ def __init__(self, shared_state, skin_scraper=None, skin_mapping=None):
self.skin_scraper = skin_scraper
self.skin_mapping = skin_mapping
self.last_skin_name: Optional[str] = None
self.swiftplay_active_champion_id: Optional[int] = None
self.swiftplay_last_champ_switch_time: float = 0.0

def process_skin_name(self, skin_name: str, broadcaster=None) -> None:
"""Process a skin name and update shared state
Expand Down Expand Up @@ -87,7 +89,31 @@ def _process_swiftplay_skin_name(self, skin_name: str, broadcaster=None) -> None
)
return

now = time.monotonic()
with self.shared_state.swiftplay_lock:
existing_skin = self.shared_state.swiftplay_skin_tracking.get(champion_id)
# Protect against background drawer-close reverts:
# If the user recently switched to a different champion B, do not let an incoming
# background revert for champion A overwrite champion A's explicitly selected skin.
if (
self.swiftplay_active_champion_id is not None
and champion_id != self.swiftplay_active_champion_id
and (now - self.swiftplay_last_champ_switch_time) < 2.5
and existing_skin is not None
and existing_skin != skin_id
):
log.info(
"[SkinMonitor] Swiftplay: Ignored background drawer revert for champion %s ('%s'), keeping selected skin %s",
champion_id,
skin_name,
existing_skin,
)
return

if self.swiftplay_active_champion_id != champion_id:
self.swiftplay_active_champion_id = champion_id
self.swiftplay_last_champ_switch_time = now

self.shared_state.swiftplay_skin_tracking[champion_id] = skin_id
tracking_snapshot = dict(self.shared_state.swiftplay_skin_tracking)
self.shared_state.ui_skin_id = skin_id
Expand Down
6 changes: 3 additions & 3 deletions threads/handlers/game_mode_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import logging
import traceback
from lcu import LCU
from lcu.core.lockfile import SWIFTPLAY_MODES, SWIFTPLAY_QUEUE_ID
from lcu.core.lockfile import SWIFTPLAY_MODES, SWIFTPLAY_QUEUE_ID, SWIFTPLAY_QUEUE_IDS
from state import SharedState
from utils.core.logging import get_logger

Expand Down Expand Up @@ -80,8 +80,8 @@ def detect_game_mode(self):
self.state.current_queue_id = queue_id

# Compute is_swiftplay_mode without intermediate False visible to other threads
# Explicit queue ID 480 check for Swiftplay (may have queue_id without gameMode)
if queue_id == SWIFTPLAY_QUEUE_ID:
# Explicit queue ID check for Swiftplay / Quickplay (480 / 490)
if queue_id in SWIFTPLAY_QUEUE_IDS or queue_id == SWIFTPLAY_QUEUE_ID:
new_swiftplay_mode = True

if isinstance(game_mode, str) and game_mode.upper() in SWIFTPLAY_MODES:
Expand Down
14 changes: 7 additions & 7 deletions threads/handlers/lobby_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import logging
import time
from lcu import LCU
from lcu.core.lockfile import SWIFTPLAY_MODES, SWIFTPLAY_QUEUE_ID
from lcu.core.lockfile import SWIFTPLAY_MODES, SWIFTPLAY_QUEUE_ID, SWIFTPLAY_QUEUE_IDS
from state import SharedState
from utils.core.logging import get_logger, log_action

Expand Down Expand Up @@ -72,18 +72,18 @@ def process_lobby_state(self, force: bool = False):
is_swiftplay = False
if detected_mode and isinstance(detected_mode, str) and detected_mode.upper() in SWIFTPLAY_MODES:
is_swiftplay = True
# Queue ID 480 fallback - reliable Swiftplay indicator even when game_mode is missing
elif detected_queue == SWIFTPLAY_QUEUE_ID:
# Queue ID fallback - reliable Swiftplay / Quickplay indicator even when game_mode is missing/CLASSIC
elif detected_queue in SWIFTPLAY_QUEUE_IDS or detected_queue == SWIFTPLAY_QUEUE_ID:
is_swiftplay = True
log.debug(f"[phase] lobby: Swiftplay via queue 480 fallback (detected_mode={detected_mode})")
log.debug(f"[phase] lobby: Swiftplay via queue {detected_queue} fallback (detected_mode={detected_mode})")
if not detected_mode:
detected_mode = "SWIFTPLAY"
# Stored queue ID fallback: only trust if we were already in Swiftplay mode
# (prevents stale queue ID from a previous session from triggering false detection)
elif detected_queue is None and self.state.current_queue_id == SWIFTPLAY_QUEUE_ID and self.state.is_swiftplay_mode:
elif detected_queue is None and (self.state.current_queue_id in SWIFTPLAY_QUEUE_IDS or self.state.current_queue_id == SWIFTPLAY_QUEUE_ID) and self.state.is_swiftplay_mode:
is_swiftplay = True
detected_queue = 480
log.debug("[phase] lobby: Swiftplay via stored queue 480 fallback (already in swiftplay mode)")
detected_queue = self.state.current_queue_id or SWIFTPLAY_QUEUE_ID
log.debug(f"[phase] lobby: Swiftplay via stored queue {detected_queue} fallback (already in swiftplay mode)")
if not detected_mode:
detected_mode = "SWIFTPLAY"
elif detected_mode is None and self.lcu.ok and self.lcu.is_swiftplay:
Expand Down
18 changes: 9 additions & 9 deletions threads/handlers/phase_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import logging
from lcu import LCU
from lcu.core.lockfile import SWIFTPLAY_QUEUE_ID
from lcu.core.lockfile import SWIFTPLAY_QUEUE_ID, SWIFTPLAY_QUEUE_IDS
from state import SharedState
from ui.chroma.selector import get_chroma_selector
from utils.core.logging import get_logger, log_action
Expand Down Expand Up @@ -58,10 +58,10 @@ def handle_phase_change(self, phase: str, previous_phase: str):
self.swiftplay_handler._injection_triggered = True

elif phase == "ChampSelect":
# Queue ID 480 fallback - handles race condition where game_mode_detector
# Queue ID (480 / 490) fallback - handles race condition where game_mode_detector
# hasn't set is_swiftplay_mode yet when we enter ChampSelect
if not self.state.is_swiftplay_mode and self.state.current_queue_id == SWIFTPLAY_QUEUE_ID:
log.info("[phase] ChampSelect - queue ID 480 detected, setting Swiftplay mode")
if not self.state.is_swiftplay_mode and (self.state.current_queue_id in SWIFTPLAY_QUEUE_IDS or self.state.current_queue_id == SWIFTPLAY_QUEUE_ID):
log.info(f"[phase] ChampSelect - queue ID {self.state.current_queue_id} detected, setting Swiftplay mode")
self.state.is_swiftplay_mode = True
# Ensure handler state is initialized
if self.swiftplay_handler:
Expand Down Expand Up @@ -156,13 +156,13 @@ def handle_phase_change(self, phase: str, previous_phase: str):
self.swiftplay_handler.cleanup_swiftplay_exit()

# Handle returning to Lobby from a Swiftplay game flow (dodge, decline, etc.)
# Only cleanup if we're NOT returning to a Swiftplay lobby (queue ID 480).
# If queue ID is still 480, user wants to requeue with same skins — preserve tracking.
# Only cleanup if we're NOT returning to a Swiftplay / Quickplay lobby (queue ID 480 / 490).
# If queue ID is still in Swiftplay/Quickplay, user wants to requeue with same skins — preserve tracking.
if phase == "Lobby" and previous_phase in _SWIFTPLAY_ACTIVE_PHASES:
if self.state.is_swiftplay_mode and self.swiftplay_handler:
# Check if we're still in a Swiftplay lobby (queue ID 480)
if self.state.current_queue_id == SWIFTPLAY_QUEUE_ID:
log.info(f"[phase] Returned to Lobby from {previous_phase} - still in Swiftplay queue (480), preserving skin tracking")
# Check if we're still in a Swiftplay / Quickplay lobby (queue ID 480 / 490)
if self.state.current_queue_id in SWIFTPLAY_QUEUE_IDS or self.state.current_queue_id == SWIFTPLAY_QUEUE_ID:
log.info(f"[phase] Returned to Lobby from {previous_phase} - still in Swiftplay/Quickplay queue ({self.state.current_queue_id}), preserving skin tracking")
else:
log.info(f"[phase] Returned to Lobby from {previous_phase} - queue changed, cleaning up Swiftplay state")
self.swiftplay_handler.cleanup_swiftplay_exit()
Expand Down
Loading