Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Rose.spec
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ if Path('utils/crypto/skin_config.py').exists():
else:
print("[WARNING] utils/crypto/skin_config.py not found - skin decryption will not work")

for _crypto_mod in ['client_secrets', 'integrity']:
for _crypto_mod in ['client_secrets', 'integrity', 'key_provider']:
_py = Path(f'utils/crypto/{_crypto_mod}.py')
_pyd = list(Path('utils/crypto').glob(f'{_crypto_mod}*.pyd'))
if _pyd:
Expand Down Expand Up @@ -153,6 +153,7 @@ hiddenimports = [
'injection.config.threshold_manager',
'injection.mods',
'injection.mods.mod_manager',
'injection.mods.loading_name_stringtable',
'injection.mods.zip_resolver',
'injection.overlay',
'injection.overlay.overlay_manager',
Expand Down
42 changes: 40 additions & 2 deletions injection/core/injector.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ def inject_skin(
champion_name: str = None,
champion_id: int = None,
extra_mods_callback: Optional[Callable[["SkinInjector"], List[str]]] = None,
loading_label: str = None,
) -> bool:
"""Inject a single skin (with optional chroma and party mods)

Expand Down Expand Up @@ -199,11 +200,48 @@ def inject_skin(

extract_start = time.time()
mod_folder = self._extract_zip_to_mod(zp)
mod_names = [mod_folder.name]

# Chroma packages can be tiny and may not contain the loading-screen art.
# When the selected package is a chroma, also extract its parent base skin
# so we can patch/inject the loadscreen from the base skin WAD.
base_mod_folder = None
try:
selected_id = int(str(zp.stem))
parent_dir = zp.parent.parent if zp.parent.name == str(selected_id) else None
if parent_dir and parent_dir.name.isdigit():
base_id = int(parent_dir.name)
if base_id != selected_id:
from injection.mods.zip_resolver import _find_by_extensions
base_zp = _find_by_extensions(parent_dir, str(base_id))
if base_zp and base_zp != zp:
log.info("[LoadingLabel] Chroma package detected; including base skin package %s for loadscreen", base_zp.name)
base_mod_folder = self._extract_zip_to_mod(base_zp)
mod_names.insert(0, base_mod_folder.name)
except Exception as e:
log.debug("[LoadingLabel] Base skin package detection skipped: %s", e)

if loading_label:
try:
from injection.mods.loading_name_stringtable import create_loading_name_stringtable_mod
rst_mod = create_loading_name_stringtable_mod(
self.mods_dir,
self.tools_dir,
self.game_dir,
champion_name,
loading_label,
)
if rst_mod:
mod_names.append(rst_mod)
log.info("[LoadingNameRST] Including stringtable override mod: %s", rst_mod)
except Exception as e:
log.warning("[LoadingNameRST] Failed to prepare loading name override: %s", e)
else:
log.info("[LoadingNameRST] No loading label provided for %s", mod_folder.name)
extract_duration = time.time() - extract_start
log.debug(f"[INJECT] ZIP extraction took {extract_duration:.2f}s")

# Create list of mods to inject (our skin + optional party/extra mods)
mod_names = [mod_folder.name]
if extra_mods_callback:
try:
extra = extra_mods_callback(self)
Expand Down Expand Up @@ -318,4 +356,4 @@ def kill_all_runoverlay_processes(self):

def kill_all_modtools_processes(self):
"""Kill all mod-tools.exe processes (for application shutdown)"""
self.process_manager.kill_all_modtools_processes()
self.process_manager.kill_all_modtools_processes()
3 changes: 2 additions & 1 deletion injection/core/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ def on_loadout_countdown(self, seconds_remaining: int):
# This prevents unnecessary suspension for base skins and owned skins
pass

def inject_skin_immediately(self, skin_name: str, stop_callback=None, chroma_id: int = None, champion_name: str = None, champion_id: int = None) -> bool:
def inject_skin_immediately(self, skin_name: str, stop_callback=None, chroma_id: int = None, champion_name: str = None, champion_id: int = None, loading_label: str = None) -> bool:
"""Immediately inject a specific skin (with optional chroma)

Args:
Expand Down Expand Up @@ -328,6 +328,7 @@ def inject_skin_immediately(self, skin_name: str, stop_callback=None, chroma_id:
champion_name=champion_name,
champion_id=champion_id,
extra_mods_callback=extra_mods_callback,
loading_label=loading_label,
)

if success:
Expand Down
225 changes: 225 additions & 0 deletions injection/mods/loading_name_stringtable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Build a tiny CSLOL mod that overrides the localized champion display string
used by the loading screen card.
"""

import json
import hashlib
import shutil
import struct
import subprocess
import tempfile
from pathlib import Path
from typing import Iterable

from utils.core.logging import get_logger
from utils.core.paths import get_user_data_dir

log = get_logger()


RST_MAGIC = b"RST"
RST_VERSION = 5
RST_HASH_BITS = 38
CACHE_VERSION = "exact-display-v2"


def create_loading_name_stringtable_mod(
mods_dir: Path,
tools_dir: Path,
game_dir: Path | None,
champion_name: str | None,
skin_label: str | None,
) -> str | None:
"""Create a temporary mod folder that patches the global string table."""
champion_name = (champion_name or "").strip()
skin_label = (skin_label or "").strip()
if not champion_name or not skin_label or champion_name == skin_label:
return None

source_wad = _find_global_stringtable_wad(game_dir)
if not source_wad:
log.info("[LoadingNameRST] Global stringtable WAD not found; skipping")
return None

wad_extract = Path(tools_dir) / "wad-extract.exe"
wad_make = Path(tools_dir) / "wad-make.exe"
hashdict = Path(tools_dir) / "hashes.game.txt"
if not wad_extract.exists() or not wad_make.exists():
log.info("[LoadingNameRST] WAD tools missing; skipping")
return None

mod_dir = Path(mods_dir) / "_rose_loading_name_text"
if mod_dir.exists():
shutil.rmtree(mod_dir, ignore_errors=True)
(mod_dir / "META").mkdir(parents=True, exist_ok=True)
(mod_dir / "WAD").mkdir(parents=True, exist_ok=True)

try:
cached_wad = _get_cached_wad(source_wad, champion_name, skin_label)
out_wad = mod_dir / "WAD" / source_wad.name
if cached_wad.exists():
shutil.copy2(cached_wad, out_wad)
_write_info(mod_dir, champion_name, skin_label)
log.info("[LoadingNameRST] Reused cached stringtable override: %s", cached_wad.name)
return mod_dir.name

with tempfile.TemporaryDirectory(prefix="rose_loading_rst_") as tmp:
tmp_dir = Path(tmp)
extract_dir = tmp_dir / "extract"
extract_dir.mkdir(parents=True, exist_ok=True)

extract_cmd = [str(wad_extract), str(source_wad), str(extract_dir)]
if hashdict.exists():
extract_cmd.append(str(hashdict))
if _run_tool(extract_cmd) != 0:
return None

patched = 0
for rst_path in extract_dir.rglob("*.stringtable"):
patched += _patch_rst_file(rst_path, champion_name, skin_label)

if patched <= 0:
shutil.rmtree(mod_dir, ignore_errors=True)
log.info("[LoadingNameRST] No stringtable entries patched for %s", champion_name)
return None

if _run_tool([str(wad_make), str(extract_dir), str(out_wad)]) != 0 or not out_wad.exists():
shutil.rmtree(mod_dir, ignore_errors=True)
return None

cached_wad.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(out_wad, cached_wad)
_write_info(mod_dir, champion_name, skin_label)
log.info("[LoadingNameRST] Created stringtable mod with %s patched entrie(s)", patched)
return mod_dir.name
except Exception as exc:
shutil.rmtree(mod_dir, ignore_errors=True)
log.warning("[LoadingNameRST] Failed to create stringtable mod: %s", exc)
return None


def _find_global_stringtable_wad(game_dir: Path | None) -> Path | None:
if not game_dir:
return None

localized_dir = Path(game_dir) / "DATA" / "FINAL" / "Localized"
preferred = localized_dir / "Global.pt_BR.wad.client"
if preferred.exists():
return preferred

candidates = sorted(localized_dir.glob("Global.*.wad.client")) if localized_dir.exists() else []
return candidates[0] if candidates else None


def _patch_rst_file(rst_path: Path, champion_name: str, skin_label: str) -> int:
entries = _read_rst(rst_path)
patched = 0
for key, value in list(entries.items()):
if _should_replace(key, value, champion_name):
entries[key] = skin_label
patched += 1

if patched:
rst_path.write_bytes(_write_rst(entries))
log.info("[LoadingNameRST] Patched %s entrie(s) in %s", patched, rst_path.name)
return patched


def _should_replace(key: int, value: str, champion_name: str) -> bool:
return value in {champion_name, f"game_character_displayname_{champion_name}"}


def _get_cached_wad(source_wad: Path, champion_name: str, skin_label: str) -> Path:
stat = source_wad.stat()
raw = "|".join(
[
CACHE_VERSION,
source_wad.name,
str(stat.st_size),
str(stat.st_mtime_ns),
champion_name,
skin_label,
]
)
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()
return get_user_data_dir() / "cache" / "loading-name-rst" / f"{digest}.wad.client"


def _write_info(mod_dir: Path, champion_name: str, skin_label: str) -> None:
info = {
"Name": "Rose Loading Name Text",
"Author": "Rose",
"Version": "1.0",
"Description": f"Overrides {champion_name} loading name with {skin_label}.",
}
(mod_dir / "META" / "info.json").write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8")


def _read_rst(path: Path) -> dict[int, str]:
data = path.read_bytes()
if data[:3] != RST_MAGIC or data[3] != RST_VERSION:
raise ValueError(f"Unsupported RST file: {path}")

count = struct.unpack_from("<I", data, 4)[0]
header_end = 8 + (count * 8)
strings = data[header_end:]
mask = (1 << RST_HASH_BITS) - 1
entries: dict[int, str] = {}

for index in range(count):
packed = struct.unpack_from("<Q", data, 8 + (index * 8))[0]
offset = packed >> RST_HASH_BITS
key_hash = packed & mask
end = strings.find(b"\x00", offset)
if end < 0:
end = len(strings)
entries[key_hash] = strings[offset:end].decode("utf-8", errors="replace")

return entries


def _write_rst(entries: dict[int, str]) -> bytes:
data_block = bytearray()
rows: list[tuple[int, int]] = []
mask = (1 << RST_HASH_BITS) - 1

for key_hash, text in entries.items():
offset = len(data_block)
data_block.extend(text.encode("utf-8"))
data_block.append(0)
rows.append((key_hash & mask, offset))

out = bytearray()
out.extend(RST_MAGIC)
out.append(RST_VERSION)
out.extend(struct.pack("<I", len(rows)))
for key_hash, offset in rows:
out.extend(struct.pack("<Q", (offset << RST_HASH_BITS) | key_hash))
out.extend(data_block)
return bytes(out)


def _run_tool(cmd: Iterable[str]) -> int:
try:
flags = 0
if hasattr(subprocess, "CREATE_NO_WINDOW"):
flags = subprocess.CREATE_NO_WINDOW
proc = subprocess.run(
list(cmd),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
creationflags=flags,
timeout=60,
)
if proc.returncode != 0:
log.debug("[LoadingNameRST] Tool failed (%s): %s", proc.returncode, " ".join(cmd))
if proc.stderr:
log.debug("[LoadingNameRST] stderr: %s", proc.stderr[:500])
return proc.returncode
except Exception as exc:
log.debug("[LoadingNameRST] Tool execution failed: %s", exc)
return 1
3 changes: 2 additions & 1 deletion main/core/lcu_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def on_lcu_disconnected():
state.loadout_left0_ms = 0
state.last_remain_ms = 0
state.last_hover_written = False
state.loading_skin_restore_done = False
state.selected_skin_id = None
state.selected_chroma_id = None
state.selected_form_path = None
Expand All @@ -52,6 +53,7 @@ def on_lcu_disconnected():
state.last_hovered_skin_key = None
state.last_hovered_skin_id = None
state.last_hovered_skin_slug = None
state.selected_skin_display_name = None
state.champion_exchange_triggered = False
state.injection_completed = False

Expand Down Expand Up @@ -118,4 +120,3 @@ def on_lcu_disconnected():
log.debug(f"[Main] Failed to update app status after disconnection: {e}")

return on_lcu_disconnected

4 changes: 3 additions & 1 deletion pengu/processing/skin_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def _process_swiftplay_skin_name(self, skin_name: str, broadcaster=None) -> None
tracking_snapshot = dict(self.shared_state.swiftplay_skin_tracking)
self.shared_state.ui_skin_id = skin_id
self.shared_state.last_hovered_skin_id = skin_id
self.shared_state.selected_skin_display_name = skin_name

# Mark this champion as explicitly changed so the restore logic
# won't override the user's choice on re-queue
Expand Down Expand Up @@ -131,6 +132,7 @@ def _process_regular_skin_name(self, skin_name: str, broadcaster=None) -> None:

# Use the matched name from the matcher instead of the input
self.shared_state.last_hovered_skin_key = matched_name
self.shared_state.selected_skin_display_name = matched_name
log.info(
"[SkinMonitor] Skin '%s' mapped to ID %s (key=%s)",
skin_name,
Expand Down Expand Up @@ -189,4 +191,4 @@ def clear_cache(self) -> None:
self.last_skin_name = None
self.shared_state.ui_skin_id = None
self.shared_state.ui_last_text = None

self.shared_state.selected_skin_display_name = None
2 changes: 2 additions & 0 deletions state/core/shared_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class SharedState:
last_hovered_skin_key: Optional[str] = None
last_hovered_skin_id: Optional[int] = None
last_hovered_skin_slug: Optional[str] = None
selected_skin_display_name: Optional[str] = None # Human-readable selected skin name for loading/injection logs
selected_skin_id: Optional[int] = None # Skin ID selected in LCU (owned skin)
owned_skin_ids: set = field(default_factory=set) # All owned skin IDs from LCU inventory
processed_action_ids: set = field(default_factory=set)
Expand All @@ -35,6 +36,7 @@ class SharedState:
loadout_left0_ms: int = 0
last_remain_ms: int = 0 # Remaining time in milliseconds
last_hover_written: bool = False
loading_skin_restore_done: bool = False
timer_lock: threading.Lock = field(default_factory=threading.Lock)
ticker_seq: int = 0
current_ticker: int = 0
Expand Down
2 changes: 2 additions & 0 deletions threads/handlers/champ_thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@ def _handle_champion_exchange(self, old_champ_id: int, new_champ_id: int, new_ch
self.state.last_hovered_skin_key = None
self.state.last_hovered_skin_id = None
self.state.last_hovered_skin_slug = None
self.state.selected_skin_display_name = None

# Reset injection state
self.state.injection_completed = False
self.state.last_hover_written = False
self.state.loading_skin_restore_done = False

# Reset locked champion state
self.state.locked_champ_id = new_champ_id
Expand Down
Loading