From cbe3fab29bf84aae27b0c0e4f74ea8b95fa2b5da Mon Sep 17 00:00:00 2001 From: Jesewe Date: Fri, 26 Jun 2026 16:42:11 +0500 Subject: [PATCH] refactor(offsets): replace multi-source system with cs2-dumper live dump - offset_fetcher.py: single path -- download cs2-dumper.exe from a2x/cs2-dumper GitHub Releases, cache in config dir, run with -f json, parse output - config_manager: remove OffsetSource, OffsetsFile, ClientDLLFile, ButtonsFile, OFFSETS_DIRECTORY from DEFAULT_CONFIG and class - client_manager: always live dump; always clear offset cache on Stop - general_settings_tab: remove entire offset source section (dropdown + file pickers) - utility.py: remove dead load_offset_sources/get_available_offset_sources forwards - error_codes: retire E4001-E4009; update E4012-E4015 for cs2-dumper - build pipeline: remove all submodule and binary bundling steps - BREAKING CHANGE: existing configs with OffsetSource key are ignored on next load --- .github/workflows/build.yml | 1 + .github/workflows/copilot-setup-steps.yml | 2 +- classes/bunnyhop.py | 4 +- classes/client_manager.py | 45 +-- classes/config_manager.py | 165 ++++------ classes/error_codes.py | 116 ++----- classes/esp.py | 48 ++- classes/ghost_manager.py | 4 +- classes/logger.py | 2 +- classes/memory_manager.py | 4 +- classes/offset_fetcher.py | 382 +++++++++++----------- classes/process_monitor.py | 2 +- classes/profile_manager.py | 9 +- classes/trigger_bot.py | 2 +- classes/updater.py | 8 +- classes/utility.py | 29 +- gui/changelog_window.py | 12 +- gui/components.py | 2 +- gui/faq_tab.py | 4 +- gui/general_settings_tab.py | 306 ++++------------- gui/home_tab.py | 350 +++++++------------- gui/logs_tab.py | 2 +- gui/main_window.py | 17 +- gui/overlay_settings_tab.py | 4 +- 24 files changed, 542 insertions(+), 978 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a10ab19..cec3e45 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -105,6 +105,7 @@ jobs: files: | ./artifact-download/VioletWing.exe prerelease: ${{ github.event.inputs.prerelease }} + - name: Send Telegram Notification if: success() run: | diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index b966944..f880386 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -12,7 +12,7 @@ on: - .github/workflows/copilot-setup-steps.yml jobs: - # This name is required — Copilot will not pick up the job under any other name. + # This name is required - Copilot will not pick up the job under any other name. copilot-setup-steps: # NOTE: VioletWing depends on pywin32 and pyMeow, which are Windows-only at runtime. # Ubuntu is used here so Copilot gets a fast, standard environment for code analysis diff --git a/classes/bunnyhop.py b/classes/bunnyhop.py index 79a9657..f0fafe1 100644 --- a/classes/bunnyhop.py +++ b/classes/bunnyhop.py @@ -85,11 +85,11 @@ def stop(self) -> None: logger.debug("Bunnyhop stopped.") def _init_address(self) -> bool: - if self.memory_manager.dwForceJump is None: + if self.memory_manager.jump is None: Logger.error_code(EC.E3001) return False try: - self.force_jump_address = self.memory_manager.client_base + self.memory_manager.dwForceJump + self.force_jump_address = self.memory_manager.client_base + self.memory_manager.jump return True except Exception as exc: logger.error("Error setting force-jump address: %s", exc) diff --git a/classes/client_manager.py b/classes/client_manager.py index a32aa3b..3bb2073 100644 --- a/classes/client_manager.py +++ b/classes/client_manager.py @@ -55,24 +55,23 @@ def _stop_feature(self, feature_name: str, feature_obj) -> bool: return False def start_client(self) -> None: - if not self.main_window.offsets: - if self.main_window._offsets_fetching: - self.main_window.update_client_status("Fetching offsets…", "#f59e0b") - return - self.main_window.update_client_status("Fetching offsets…", "#f59e0b") - self.main_window.fetch_offsets_async(on_success=self.start_client) - return - + # cs2-dumper needs CS2 running before it can dump -- check upfront so + # the user sees a clear error rather than a cryptic subprocess failure. if not is_game_running(): messagebox.showerror( "Game Not Running", - "Could not find cs2.exe. Make sure the game is running.", + "Could not find cs2.exe. Launch CS2 before starting the client.", ) return - # Only initialize if we don't already have a valid memory handle. - # Re-initializing while features are running replaces the shared pymem - # handle underneath active threads. + if not self.main_window.offsets: + if self.main_window._offsets_fetching: + self.main_window.update_client_status("Dumping offsets…", "#f59e0b") + return + self.main_window.update_client_status("Dumping offsets…", "#f59e0b") + self.main_window.fetch_offsets_async(on_success=self.start_client) + return + if not self.memory_manager.is_initialized: if not self.memory_manager.initialize(): messagebox.showerror( @@ -88,7 +87,7 @@ def start_client(self) -> None: if not config["General"].get(config_key, False): continue name = feature_data["name"] - obj = feature_data["instance"] + obj = feature_data["instance"] if getattr(obj, "is_running", False): logger.info("%s is already running.", name) any_started = True @@ -113,6 +112,16 @@ def stop_client(self) -> None: # Reset the memory handle so the next start_client gets a fresh attach. self.memory_manager.reset() + # Always clear the offset cache on Stop so the next Start re-dumps + # from live memory -- guarantees fresh offsets after a CS2 update. + self.main_window.offsets = {} + self.main_window.client_data = {} + self.main_window.buttons_data = {} + self.main_window.memory_manager.offsets = {} + self.main_window.memory_manager.client_data = {} + self.main_window.memory_manager.buttons_data = {} + logger.debug("Offset cache cleared -- will re-dump on next Start.") + if stopped_any: self.main_window.update_client_status("Inactive", "#ef4444") else: @@ -121,19 +130,16 @@ def stop_client(self) -> None: def apply_feature_state_changes(self, old_config: dict, new_config: dict) -> None: """Stop features whose enabled flag was turned off. - Intentionally does NOT start features — that is exclusively the job of + Intentionally does NOT start features - that is exclusively the job of start_client(). Toggling a checkbox in General Settings is a config change, not a start command. """ for key, feature_data in self.features.items(): - old_on = old_config["General"].get(key, False) - new_on = new_config["General"].get(key, False) + old_on = old_config["General"].get(key, False) + new_on = new_config["General"].get(key, False) running = getattr(feature_data["instance"], "is_running", False) - if old_on == new_on: continue - - # Only act on features that were turned OFF while running. if not new_on and running: self._stop_feature(feature_data["name"], feature_data["instance"]) @@ -151,6 +157,5 @@ def update_running_feature_configs(self, new_config: dict) -> None: instance.update_config(new_config) logger.debug("Config updated for %s.", feature_data["name"]) any_running = True - status, color = ("Active", "#22c55e") if any_running else ("Inactive", "#ef4444") self.main_window.update_client_status(status, color) \ No newline at end of file diff --git a/classes/config_manager.py b/classes/config_manager.py index bc7cc98..14ec6af 100644 --- a/classes/config_manager.py +++ b/classes/config_manager.py @@ -13,17 +13,8 @@ logger = Logger.get_logger(__name__) class ConfigManager: - """ - Thread-safe configuration manager for the application. - - Provides methods to load and save configuration settings with: - - Automatic caching for performance - - Thread-safe operations - - Automatic migration of missing keys - - Validation and error recovery - """ - - # Application version + """Thread-safe configuration manager for the application.""" + VERSION = "v1.3.3.0" # Directory paths @@ -36,7 +27,6 @@ class ConfigManager: ) CONFIG_DIRECTORY = str(_BASE) UPDATE_DIRECTORY = str(_BASE / "Update") - OFFSETS_DIRECTORY = str(_BASE / "Offsets") CONFIG_FILE = _BASE / "config.json" # Default configuration settings @@ -44,61 +34,57 @@ class ConfigManager: "user_id": None, "seen_changelog_version": None, "General": { - "Trigger": False, - "Overlay": False, - "Bunnyhop": False, - "Noflash": False, - "Disguise": False, + "Trigger": False, + "Overlay": False, + "Bunnyhop": False, + "Noflash": False, + "Disguise": False, "DetailedLogs": False, - "OffsetSource": "a2x", - "OffsetsFile": str(Path(OFFSETS_DIRECTORY) / "offsets.json"), - "ClientDLLFile": str(Path(OFFSETS_DIRECTORY) / "client_dll.json"), - "ButtonsFile": str(Path(OFFSETS_DIRECTORY) / "buttons.json") }, "Trigger": { - "TriggerKey": "x", - "ToggleMode": False, + "TriggerKey": "x", + "ToggleMode": False, "AttackOnTeammates": False, "active_weapon_type": "Rifles", "WeaponSettings": { "Pistols": {"ShotDelayMin": 0.02, "ShotDelayMax": 0.04, "PostShotDelay": 0.02}, - "Rifles": {"ShotDelayMin": 0.01, "ShotDelayMax": 0.03, "PostShotDelay": 0.02}, - "Snipers": {"ShotDelayMin": 0.05, "ShotDelayMax": 0.1, "PostShotDelay": 0.5}, - "SMGs": {"ShotDelayMin": 0.01, "ShotDelayMax": 0.02, "PostShotDelay": 0.05}, - "Heavy": {"ShotDelayMin": 0.03, "ShotDelayMax": 0.05, "PostShotDelay": 0.2} - } + "Rifles": {"ShotDelayMin": 0.01, "ShotDelayMax": 0.03, "PostShotDelay": 0.02}, + "Snipers": {"ShotDelayMin": 0.05, "ShotDelayMax": 0.1, "PostShotDelay": 0.5}, + "SMGs": {"ShotDelayMin": 0.01, "ShotDelayMax": 0.02, "PostShotDelay": 0.05}, + "Heavy": {"ShotDelayMin": 0.03, "ShotDelayMax": 0.05, "PostShotDelay": 0.2}, + }, }, "Overlay": { - "target_fps": 60, - "enable_box": True, - "enable_skeleton": False, - "draw_snaplines": True, + "target_fps": 60, + "enable_box": True, + "enable_skeleton": False, + "draw_snaplines": True, "snaplines_color_hex": "#FFFFFF", "box_line_thickness": 1.0, - "box_color_hex": "#FFA500", - "text_color_hex": "#FFFFFF", + "box_color_hex": "#FFA500", + "text_color_hex": "#FFFFFF", "draw_health_numbers": True, "use_transliteration": False, - "draw_nicknames": True, - "draw_teammates": False, - "teammate_color_hex": "#00FFFF" + "draw_nicknames": True, + "draw_teammates": False, + "teammate_color_hex": "#00FFFF", }, "Bunnyhop": { - "JumpKey": "space", - "JumpDelay": 0.01 + "JumpKey": "space", + "JumpDelay": 0.01, }, "NoFlash": { - "FlashSuppressionStrength": 0.0 + "FlashSuppressionStrength": 0.0, }, "GitHub": { - "AccessToken": None - } + "AccessToken": None, + }, } # Cache and thread safety - RLock allows set_value to re-enter load_config _config_cache: Optional[Dict[str, Any]] = None _lock: threading.RLock = threading.RLock() - + @classmethod def load_config(cls) -> Dict[str, Any]: """ @@ -115,8 +101,7 @@ def load_config(cls) -> Dict[str, Any]: # Fast path: return cached config if available (no lock needed for read) if cls._config_cache is not None: return copy.deepcopy(cls._config_cache) - - # Slow path: load from file (needs lock) + with cls._lock: # Double-check pattern: another thread might have loaded it if cls._config_cache is not None: @@ -130,37 +115,32 @@ def load_config(cls) -> Dict[str, Any]: cls._create_default_config() else: cls._load_existing_config() - + return copy.deepcopy(cls._config_cache) - + @classmethod def _ensure_directories(cls) -> None: """Ensure all required directories exist.""" try: Path(cls.CONFIG_DIRECTORY).mkdir(parents=True, exist_ok=True) - Path(cls.OFFSETS_DIRECTORY).mkdir(parents=True, exist_ok=True) except OSError as e: Logger.error_code(EC.E1001, "%s", e) - + @classmethod def _create_default_config(cls) -> None: """Create a new configuration file with default settings.""" - logger.info(f"config.json not found at {cls.CONFIG_FILE}, creating default configuration.") + logger.info("config.json not found at %s, creating default.", cls.CONFIG_FILE) default_copy = copy.deepcopy(cls.DEFAULT_CONFIG) cls._config_cache = default_copy cls._save_to_file(default_copy, log_info=False) - + @classmethod def _load_existing_config(cls) -> None: """Load configuration from existing file.""" try: - file_bytes = cls.CONFIG_FILE.read_bytes() - loaded_config = orjson.loads(file_bytes) - - # Validate that loaded config is a dictionary + loaded_config = orjson.loads(cls.CONFIG_FILE.read_bytes()) if not isinstance(loaded_config, dict): raise ValueError("Configuration file does not contain a valid dictionary") - cls._config_cache = loaded_config logger.debug("Loaded configuration.") @@ -168,13 +148,12 @@ def _load_existing_config(cls) -> None: if cls._update_config(cls.DEFAULT_CONFIG, cls._config_cache): logger.info("Configuration updated with missing keys.") cls._save_to_file(cls._config_cache, log_info=False) - except (orjson.JSONDecodeError, IOError, ValueError) as e: Logger.error_code(EC.E1002, "%s", e) default_copy = copy.deepcopy(cls.DEFAULT_CONFIG) cls._config_cache = default_copy cls._save_to_file(default_copy, log_info=False) - + @classmethod def _update_config(cls, default: Dict[str, Any], current: Dict[str, Any]) -> bool: """ @@ -192,12 +171,12 @@ def _update_config(cls, default: Dict[str, Any], current: Dict[str, Any]) -> boo if key not in current: current[key] = copy.deepcopy(value) updated = True - logger.debug(f"Added missing config key: {key}") + logger.debug("Added missing config key: %s", key) elif isinstance(value, dict) and isinstance(current.get(key), dict): if cls._update_config(value, current[key]): updated = True return updated - + @classmethod def save_config(cls, config: Dict[str, Any], log_info: bool = True) -> bool: """ @@ -215,35 +194,19 @@ def save_config(cls, config: Dict[str, Any], log_info: bool = True) -> bool: cls._config_cache = copy.deepcopy(config) # Save to file return cls._save_to_file(config, log_info) - + @classmethod def _save_to_file(cls, config: Dict[str, Any], log_info: bool = True) -> bool: - """ - Internal method to save configuration to file. - - Args: - config: Configuration dictionary to save - log_info: Whether to log the save operation - - Returns: - True if save was successful, False otherwise - """ try: - # Ensure directory exists Path(cls.CONFIG_DIRECTORY).mkdir(parents=True, exist_ok=True) - - # Serialize and write configuration - config_bytes = orjson.dumps(config, option=orjson.OPT_INDENT_2) - cls.CONFIG_FILE.write_bytes(config_bytes) - + cls.CONFIG_FILE.write_bytes(orjson.dumps(config, option=orjson.OPT_INDENT_2)) if log_info: - logger.info(f"Saved configuration to {cls.CONFIG_FILE}.") + logger.info("Saved configuration to %s.", cls.CONFIG_FILE) return True - except (OSError, IOError) as e: Logger.error_code(EC.E1003, "%s", e) return False - + @classmethod def reset_to_default(cls) -> Dict[str, Any]: """ @@ -258,14 +221,14 @@ def reset_to_default(cls) -> Dict[str, Any]: cls._save_to_file(default_copy, log_info=True) logger.info("Configuration reset to default values.") return copy.deepcopy(default_copy) - + @classmethod def invalidate_cache(cls) -> None: """Invalidate the configuration cache, forcing a reload on next access.""" with cls._lock: cls._config_cache = None logger.debug("Configuration cache invalidated.") - + @classmethod def get_value(cls, *keys: str, default: Any = None) -> Any: """ @@ -284,16 +247,14 @@ def get_value(cls, *keys: str, default: Any = None) -> Any: # Ensure cache is populated without paying for a deepcopy on every read. if cls._config_cache is None: cls.load_config() - current = cls._config_cache for key in keys: if isinstance(current, dict) and key in current: current = current[key] else: return default - return current - + @classmethod def set_value(cls, *keys: str, value: Any) -> bool: """ @@ -315,43 +276,41 @@ def set_value(cls, *keys: str, value: Any) -> bool: with cls._lock: config = cls.load_config() current = config - for key in keys[:-1]: if key not in current: current[key] = {} current = current[key] - current[keys[-1]] = value return cls._save_to_file(config, log_info=False) # Color choices for Overlay COLOR_CHOICES = { "Orange": "#FFA500", - "Red": "#FF0000", - "Green": "#00FF00", - "Blue": "#0000FF", - "White": "#FFFFFF", - "Black": "#000000", - "Cyan": "#00FFFF", - "Yellow": "#FFFF00" + "Red": "#FF0000", + "Green": "#00FF00", + "Blue": "#0000FF", + "White": "#FFFFFF", + "Black": "#000000", + "Cyan": "#00FFFF", + "Yellow": "#FFFF00", } # Import pyMeow colors try: from pyMeow import get_color, fade_color - + class Colors: """Pre-defined colors for overlay rendering using pyMeow.""" orange = get_color("orange") - black = get_color("black") - cyan = get_color("cyan") - white = get_color("white") - grey = fade_color(get_color("#242625"), 0.7) - red = get_color("red") - green = get_color("green") - blue = get_color("blue") + black = get_color("black") + cyan = get_color("cyan") + white = get_color("white") + grey = fade_color(get_color("#242625"), 0.7) + red = get_color("red") + green = get_color("green") + blue = get_color("blue") yellow = get_color("yellow") - + except ImportError: logger.warning("pyMeow not available, Colors class will not be initialized") Colors = None \ No newline at end of file diff --git a/classes/error_codes.py b/classes/error_codes.py index ac7bd58..efcfa63 100644 --- a/classes/error_codes.py +++ b/classes/error_codes.py @@ -8,20 +8,14 @@ class _Entry: solution: str -# --------------------------------------------------------------------------- -# E0xxx — Startup / Init -# --------------------------------------------------------------------------- - +# E0xxx - Startup / Init E0001 = _Entry( id="E0001", label="Resource path resolution failed", solution="A bundled asset could not be located. Reinstall VioletWing or run from the correct working directory.", ) -# --------------------------------------------------------------------------- -# E1xxx — Config / File I/O -# --------------------------------------------------------------------------- - +# E1xxx - Config / File I/O E1001 = _Entry( id="E1001", label="Config directory creation failed", @@ -64,10 +58,7 @@ class _Entry: solution="The selected profile file could not be read or is malformed. Delete the profile and save it again, or check for JSON syntax errors in the profiles directory.", ) -# --------------------------------------------------------------------------- -# E2xxx — Memory / Offsets -# --------------------------------------------------------------------------- - +# E2xxx - Memory / Offsets E2001 = _Entry( id="E2001", label="CS2 process not found", @@ -100,24 +91,21 @@ class _Entry: E2006 = _Entry( id="E2006", - label="Stale offsets — game updated", + label="Stale offsets - game updated", solution="CS2 was updated and the current offsets are no longer valid. Wait for cs2-dumper to publish new offsets (usually within a few hours), then restart VioletWing.", ) E2007 = _Entry( id="E2007", - label="Offset init error — missing keys", - solution="The offset data is incomplete. Restart VioletWing to re-fetch offsets. If the error persists, CS2 may have just updated — wait and try again.", + label="Offset init error - missing keys", + solution="The offset data is incomplete. Restart VioletWing to re-fetch offsets. If the error persists, CS2 may have just updated - wait and try again.", ) -# --------------------------------------------------------------------------- -# E3xxx — Features (Bunnyhop, ESP, TriggerBot, NoFlash) -# --------------------------------------------------------------------------- - +# E3xxx - Features (Bunnyhop, ESP, TriggerBot, NoFlash) E3001 = _Entry( id="E3001", label="Bunnyhop address init failed", - solution="The dwForceJump offset is not available. Ensure offsets loaded successfully (check for E2xxx errors above). Bunnyhop is disabled until resolved.", + solution="The jump offset is not available. Ensure offsets loaded successfully (check for E2xxx errors above). Bunnyhop is disabled until resolved.", ) E3002 = _Entry( @@ -162,68 +150,11 @@ class _Entry: solution="VioletWing could not stop the mouse listener cleanly. This is usually harmless. Restart VioletWing if mouse input behaves unexpectedly afterward.", ) -# --------------------------------------------------------------------------- -# E4xxx — Network / Updates / Offsets fetch -# --------------------------------------------------------------------------- - -E4001 = _Entry( - id="E4001", - label="All offset sources exhausted", - solution="VioletWing tried every configured offset source and all failed. Check your internet connection and firewall. If the problem persists, use the Local Files option and supply offset JSONs manually.", -) - -E4002 = _Entry( - id="E4002", - label="Local offset files missing", - solution="One or more of offsets.json, client_dll.json, or buttons.json could not be found. Place the files in the VioletWing config directory or switch to an online offset source in Settings.", -) - -E4003 = _Entry( - id="E4003", - label="Local offset files invalid", - solution="The local offset files are present but missing required fields. Re-download them from a cs2-dumper release or switch to an online source in Settings.", -) - -E4004 = _Entry( - id="E4004", - label="Local offset files read error", - solution="VioletWing could not read the local offset files. Ensure the files are valid JSON and that you have read access to the config directory.", -) - -E4005 = _Entry( - id="E4005", - label="Unknown offset source", - solution="The offset source configured in Settings does not exist. Open Settings → General and select a valid source from the dropdown.", -) - -E4006 = _Entry( - id="E4006", - label="Remote offset fetch HTTP error", - solution="The offset server returned an error. This is usually temporary — wait a few minutes and restart VioletWing. If the error persists, try switching to a different offset source in Settings.", -) - -E4007 = _Entry( - id="E4007", - label="Remote offset files invalid", - solution="Offsets were downloaded but are missing required fields. CS2 may have just updated. Wait for the offset source to publish updated files, then restart VioletWing.", -) - -E4008 = _Entry( - id="E4008", - label="Remote offset fetch failed", - solution="VioletWing could not reach the offset server. Check your internet connection and firewall rules. You can also switch to Local Files in Settings and supply offsets manually.", -) - -E4009 = _Entry( - id="E4009", - label="Local offsets failed — fallback triggered", - solution="Local offset files could not be loaded. VioletWing is falling back to the a2x online source. Check E4002–E4004 for the specific cause.", -) - +# E4xxx - Network / Updates / Offsets fetch E4010 = _Entry( id="E4010", label="Update check failed", - solution="VioletWing could not reach the update server. This is non-critical — all features still work. Check your internet connection if you want to verify your version.", + solution="VioletWing could not reach the update server. This is non-critical - all features still work. Check your internet connection if you want to verify your version.", ) E4011 = _Entry( @@ -232,10 +163,31 @@ class _Entry: solution="VioletWing downloaded an update but could not apply it. Check that the update directory is writable and that no antivirus is blocking the installer. You can update manually from the GitHub releases page.", ) -# --------------------------------------------------------------------------- -# Lookup helpers -# --------------------------------------------------------------------------- +E4012 = _Entry( + id="E4012", + label="cs2-dumper cannot run - CS2 is not running", + solution="Launch CS2 and wait for it to reach the main menu before starting VioletWing. cs2-dumper reads live process memory and requires an active CS2 process.", +) + +E4013 = _Entry( + id="E4013", + label="cs2-dumper subprocess failed", + solution="cs2-dumper exited with an error. Run VioletWing as Administrator and ensure CS2 is fully loaded into the main menu. Check the detailed log for cs2-dumper's stderr output. If cs2-dumper is outdated after a CS2 update, it will be re-downloaded automatically on the next attempt.", +) + +E4014 = _Entry( + id="E4014", + label="cs2-dumper binary missing or could not be downloaded", + solution="VioletWing failed to download cs2-dumper.exe from GitHub Releases. Check your internet connection and firewall. If the problem persists, manually place cs2-dumper.exe in the VioletWing config directory.", +) + +E4015 = _Entry( + id="E4015", + label="cs2-dumper output files missing or invalid", + solution="cs2-dumper ran but did not produce valid output. Ensure CS2 is fully loaded into the main menu (not mid-loading) before retrying. Check the detailed log for cs2-dumper's own error output.", +) +# Lookup helpers CATALOG: dict[str, _Entry] = { v.id: v for k, v in globals().items() diff --git a/classes/esp.py b/classes/esp.py index d3c84ff..037781d 100644 --- a/classes/esp.py +++ b/classes/esp.py @@ -41,10 +41,10 @@ class Entity: """Game entity with cached per-frame data.""" - def __init__(self, mm: MemoryManager) -> None: + def __init__(self, controller_ptr: int, pawn_ptr: int, mm: MemoryManager) -> None: + self.controller_ptr = controller_ptr + self.pawn_ptr = pawn_ptr self.memory_manager = mm - self.controller_ptr: int = 0 - self.pawn_ptr: int = 0 self.pos2d: Optional[Dict[str, float]] = None self.head_pos2d: Optional[Dict[str, float]] = None self.name: str = "" @@ -54,9 +54,7 @@ def __init__(self, mm: MemoryManager) -> None: self.dormant: bool = True self.all_bones_pos_3d: Optional[Dict[int, Dict[str, float]]] = None - def update(self, controller_ptr: int, pawn_ptr: int, use_transliteration: bool, skeleton_enabled: bool) -> bool: - self.controller_ptr = controller_ptr - self.pawn_ptr = pawn_ptr + def update(self, use_transliteration: bool, skeleton_enabled: bool) -> bool: try: self.health = self.memory_manager.read_int(self.pawn_ptr + self.memory_manager.m_iHealth) if self.health <= 0: @@ -106,6 +104,13 @@ def _all_bone_pos(self) -> Optional[Dict[int, Dict[str, float]]]: logger.debug("Failed to get all bone positions: %s", exc) return None + @staticmethod + def validate_screen_position(pos: Dict[str, float]) -> bool: + return ( + 0 <= pos["x"] <= overlay.get_screen_width() + and 0 <= pos["y"] <= overlay.get_screen_height() + ) + class CS2Overlay(BaseFeature): def __init__(self, memory_manager: MemoryManager) -> None: super().__init__(memory_manager) @@ -113,7 +118,6 @@ def __init__(self, memory_manager: MemoryManager) -> None: self.local_team: Optional[int] = None self.screen_width = overlay.get_screen_width() self.screen_height = overlay.get_screen_height() - self._entity_pool = [Entity(self.memory_manager) for _ in range(ENTITY_COUNT)] self.load_configuration() def load_configuration(self) -> None: @@ -131,7 +135,6 @@ def load_configuration(self) -> None: self.draw_teammates = s["draw_teammates"] self.teammate_color_hex = s["teammate_color_hex"] self.target_fps = int(s["target_fps"]) - self._frame_time = 1.0 / max(self.target_fps, 1) self._resolve_colors() def update_config(self, config: dict) -> None: @@ -154,6 +157,7 @@ def start(self) -> None: sleep = time.sleep while not self.stop_event.is_set(): + frame_time = 1.0 / max(self.target_fps, 1) start = time.time() try: if not is_game_active(): @@ -190,7 +194,7 @@ def start(self) -> None: overlay.end_drawing() elapsed = time.time() - start - slack = self._frame_time - elapsed + slack = frame_time - elapsed if slack > 0: sleep(slack) @@ -226,8 +230,8 @@ def _resolve_colors(self) -> None: self._color_panel_bg = self._color_panel_border = None def _world_to_screen(self, vm: list, pos: dict) -> dict | None: - sx = self.screen_width / 2 - sy = self.screen_height / 2 + sx = overlay.get_screen_width() / 2 + sy = overlay.get_screen_height() / 2 w = vm[12]*pos["x"] + vm[13]*pos["y"] + vm[14]*pos["z"] + vm[15] if w <= 0.01: return None @@ -235,12 +239,6 @@ def _world_to_screen(self, vm: list, pos: dict) -> dict | None: y = sy - (vm[4]*pos["x"] + vm[5]*pos["y"] + vm[6]*pos["z"] + vm[7]) / w * sy return {"x": x, "y": y} - def _is_on_screen(self, pos: dict) -> bool: - return ( - 0 <= pos["x"] <= self.screen_width - and 0 <= pos["y"] <= self.screen_height - ) - def _iterate_entities(self, local_ctrl: int) -> Iterator[Entity]: try: ent_list = self.memory_manager.read_longlong( @@ -250,7 +248,6 @@ def _iterate_entities(self, local_ctrl: int) -> Iterator[Entity]: logger.debug("Error reading entity list: %s", exc) return - pool_idx = 0 for i in range(1, ENTITY_COUNT + 1): try: list_idx = (i & 0x7FFF) >> 9 @@ -274,14 +271,9 @@ def _iterate_entities(self, local_ctrl: int) -> Iterator[Entity]: ) if not pawn: continue - - if pool_idx >= len(self._entity_pool): - break - - ent = self._entity_pool[pool_idx] - if ent.update(ctrl, pawn, self.use_transliteration, self.enable_skeleton): + ent = Entity(ctrl, pawn, self.memory_manager) + if ent.update(self.use_transliteration, self.enable_skeleton): yield ent - pool_idx += 1 except Exception as exc: logger.debug("Failed to read entity %d: %s", i, exc) @@ -290,7 +282,7 @@ def _draw_watermark(self) -> None: size = 14 pad_x, pad_y = 8, 5 w = overlay.measure_text(text, size) - sw = self.screen_width + sw = overlay.get_screen_width() fw = w + pad_x * 2 fh = size + pad_y * 2 fx = sw - fw - 10 @@ -307,7 +299,7 @@ def _draw_skeleton(self, ent: Entity, vm: list, color: tuple) -> None: for bid in ALL_BONE_IDS: if bid in ent.all_bones_pos_3d: p2 = self._world_to_screen(vm, ent.all_bones_pos_3d[bid]) - if p2 and self._is_on_screen(p2): + if p2 and ent.validate_screen_position(p2): pts[bid] = p2 for start, ends in SKELETON_BONES.items(): if start in pts: @@ -325,7 +317,7 @@ def _draw_entity(self, ent: Entity, vm: list, is_teammate: bool = False) -> None head2d = self._world_to_screen(vm, head3d) if pos2d is None or head2d is None: return - if not self._is_on_screen(pos2d) or not self._is_on_screen(head2d): + if not ent.validate_screen_position(pos2d) or not ent.validate_screen_position(head2d): return ent.pos2d = pos2d diff --git a/classes/ghost_manager.py b/classes/ghost_manager.py index cf4d912..cddef02 100644 --- a/classes/ghost_manager.py +++ b/classes/ghost_manager.py @@ -18,7 +18,7 @@ def _load() -> list[dict]: with open(path, "r", encoding="utf-8") as fh: data = json.load(fh) except FileNotFoundError: - logger.warning("ghosts.json not found at %s — disguise disabled.", path) + logger.warning("ghosts.json not found at %s - disguise disabled.", path) return [] except json.JSONDecodeError as exc: Logger.error_code(EC.E1004, "%s", exc) @@ -37,7 +37,7 @@ def _load() -> list[dict]: valid.append(entry) if not valid: - logger.warning("No valid ghost profiles found — disguise disabled.") + logger.warning("No valid ghost profiles found - disguise disabled.") return valid diff --git a/classes/logger.py b/classes/logger.py index 43f59f3..0aa8eb4 100644 --- a/classes/logger.py +++ b/classes/logger.py @@ -319,7 +319,7 @@ def error_code(entry, *args, **kwargs) -> None: # Append caller detail after the catalog label detail_fmt = args[0] detail_args = args[1:] - msg = f"{base} — {detail_fmt}" + msg = f"{base} - {detail_fmt}" Logger.get_logger().error(msg, *detail_args, **kwargs) else: Logger.get_logger().error(base, **kwargs) diff --git a/classes/memory_manager.py b/classes/memory_manager.py index d9171be..a131070 100644 --- a/classes/memory_manager.py +++ b/classes/memory_manager.py @@ -34,7 +34,7 @@ def __init__(self, offsets: dict, client_data: dict, buttons_data: dict) -> None self.m_hPlayerPawn = None self.m_flFlashBangTime = None self.m_pBoneArray = None - self.dwForceJump = None + self.jump = None self.m_AttributeManager = None self.m_iItemDefinitionIndex = None self.m_Item = None @@ -109,7 +109,7 @@ def load_offsets(self) -> None: self.dwLocalPlayerPawn = extracted["dwLocalPlayerPawn"] self.dwLocalPlayerController = extracted["dwLocalPlayerController"] self.dwViewMatrix = extracted["dwViewMatrix"] - self.dwForceJump = extracted["dwForceJump"] + self.jump = extracted["jump"] self.m_iHealth = extracted["m_iHealth"] self.m_iTeamNum = extracted["m_iTeamNum"] self.m_iIDEntIndex = extracted["m_iIDEntIndex"] diff --git a/classes/offset_fetcher.py b/classes/offset_fetcher.py index e406b34..a5fcf05 100644 --- a/classes/offset_fetcher.py +++ b/classes/offset_fetcher.py @@ -1,5 +1,7 @@ import os -from concurrent.futures import ThreadPoolExecutor +import subprocess +import sys +import tempfile from pathlib import Path import orjson @@ -12,187 +14,43 @@ logger = Logger.get_logger(__name__) -_REMOTE_SOURCES_URL = "https://violetwing.vercel.app/data/offsets.json" -_STATUS_URL = "https://violetwing.vercel.app/data/status.json" _GITHUB_RELEASES_URL = "https://api.github.com/repos/{repo}/releases/latest" -_REQUIRED_SOURCE_KEYS = {"name", "author", "repository", "offsets_url", "client_dll_url", "buttons_url"} - -_DEFAULT_SOURCES: dict = { - "a2x": { - "name": "A2X Source", - "author": "a2x", - "repository": "a2x/cs2-dumper", - "offsets_url": "https://raw.githubusercontent.com/a2x/cs2-dumper/main/output/offsets.json", - "client_dll_url": "https://raw.githubusercontent.com/a2x/cs2-dumper/main/output/client_dll.json", - "buttons_url": "https://raw.githubusercontent.com/a2x/cs2-dumper/main/output/buttons.json", - } -} - -_sources_cache: dict | None = None - -def load_offset_sources() -> dict: - """ - Load available offset sources from the remote catalogue. - - Result is cached for the lifetime of the process. Falls back to - _DEFAULT_SOURCES on any network or parse failure. - """ - global _sources_cache - if _sources_cache is not None: - return _sources_cache - - try: - resp = requests.get(_REMOTE_SOURCES_URL, timeout=10) - resp.raise_for_status() - raw: dict = orjson.loads(resp.content) - valid = {sid: cfg for sid, cfg in raw.items() if _REQUIRED_SOURCE_KEYS.issubset(cfg)} - for sid in raw: - if sid not in valid: - logger.error("Source '%s' missing required keys - skipped.", sid) +# cs2-dumper is the sole offset source. +_CS2_DUMPER_REPO = "a2x/cs2-dumper" +_CS2_DUMPER_EXE_NAME = "cs2-dumper.exe" - logger.debug("Loaded %d offset sources from remote.", len(valid)) - _sources_cache = valid - return valid +# Binary is cached in the config directory so it persists across runs and +# can be refreshed independently of VioletWing releases. +_CS2_DUMPER_EXE_PATH = Path(ConfigManager.CONFIG_DIRECTORY) / _CS2_DUMPER_EXE_NAME - except Exception as exc: - logger.warning("Could not load remote offset sources (%s) - using defaults.", exc) - _sources_cache = _DEFAULT_SOURCES - return _DEFAULT_SOURCES - -def get_available_offset_sources() -> list[dict]: - """Return a UI-friendly list of available sources including the local-files option.""" - sources = load_offset_sources() - result = [ - { - "id": sid, - "name": cfg["name"], - "author": cfg["author"], - "display": f"{cfg['name']} ({cfg['author']})", - } - for sid, cfg in sources.items() - ] - result.append({"id": "local", "name": "Local Files", "author": "User", "display": "Local Files"}) - return result +# Subprocess timeout in seconds -- cs2-dumper typically finishes in <10s. +_SUBPROCESS_TIMEOUT = 120 +# Public API def fetch_offsets() -> tuple[dict | None, dict | None, dict | None]: - """ - Fetch and validate the three offset JSON files. + """Run cs2-dumper against the live CS2 process and return parsed offsets. - Reads the configured source from ConfigManager. Falls back from - local → a2x on any failure. Returns (None, None, None) if all - sources are exhausted. + Returns (offsets, client_data, buttons_data) on success, (None, None, None) + on any failure. Failures are logged with structured error codes. """ - config = ConfigManager.load_config() - source = config["General"].get("OffsetSource", "a2x") - tried: set[str] = set() - - while source not in tried: - tried.add(source) - - if source == "local": - result = _fetch_local(config) - if result is not None: - return result - Logger.error_code(EC.E4009) - source = "a2x" - config["General"]["OffsetSource"] = source - ConfigManager.save_config(config) - continue - - result = _fetch_remote(source) - if result is not None: - return result - return None, None, None - - Logger.error_code(EC.E4001) - return None, None, None - -def _fetch_local(config: dict) -> tuple | None: - config_dir = Path(ConfigManager.CONFIG_DIRECTORY) - offsets_path = Path(config.get("General", {}).get("OffsetsFile", config_dir / "offsets.json")) - client_path = Path(config.get("General", {}).get("ClientDLLFile", config_dir / "client_dll.json")) - buttons_path = Path(config.get("General", {}).get("ButtonsFile", config_dir / "buttons.json")) - - try: - missing = [f.name for f in [offsets_path, client_path, buttons_path] if not f.exists()] - if missing: - Logger.error_code(EC.E4002, "Missing: %s", ", ".join(missing)) - return None - - offsets = orjson.loads(offsets_path.read_bytes()) - client = orjson.loads(client_path.read_bytes()) - buttons = orjson.loads(buttons_path.read_bytes()) - - if not _validate(offsets, client, buttons): - Logger.error_code(EC.E4003) - return None - - logger.info("Loaded and validated local offsets.") - return offsets, client, buttons - - except (orjson.JSONDecodeError, IOError) as exc: - Logger.error_code(EC.E4004, "%s", exc) - return None - except Exception: - logger.exception("Unexpected error loading local offsets.") - return None - -def _fetch_remote(source: str) -> tuple | None: - available = load_offset_sources() - if source not in available: - Logger.error_code(EC.E4005, "Source id: '%s'", source) - return None - - cfg = available[source] - urls = { - "offsets": os.getenv("OFFSETS_URL", cfg["offsets_url"]), - "client_dll": os.getenv("CLIENT_DLL_URL", cfg["client_dll_url"]), - "buttons": os.getenv("BUTTONS_URL", cfg["buttons_url"]), - } + from classes.game_process import is_game_running - try: - logger.debug("Fetching offsets from %s (%s)…", cfg["name"], cfg["author"]) - with ThreadPoolExecutor(max_workers=3) as ex: - futures = {k: ex.submit(requests.get, url) for k, url in urls.items()} - responses = {k: f.result() for k, f in futures.items()} - - for label, resp in responses.items(): - if resp.status_code != 200: - Logger.error_code(EC.E4006, "HTTP %d fetching %s from %s", resp.status_code, label, cfg["name"]) - return None - - offsets = orjson.loads(responses["offsets"].content) - client = orjson.loads(responses["client_dll"].content) - buttons = orjson.loads(responses["buttons"].content) - - if not _validate(offsets, client, buttons): - Logger.error_code(EC.E4007, "Source: %s", cfg["name"]) - return None + if not is_game_running(): + Logger.error_code(EC.E4012) + return None, None, None - logger.info("Successfully loaded offsets from %s.", cfg["name"]) - return offsets, client, buttons + if not _ensure_binary(): + return None, None, None - except (orjson.JSONDecodeError, requests.exceptions.RequestException) as exc: - Logger.error_code(EC.E4008, "Source: %s — %s", cfg["name"], exc) - return None - except Exception: - logger.exception("Unexpected error fetching from %s.", cfg["name"]) - return None + with tempfile.TemporaryDirectory(prefix="violetwing_dump_") as tmp: + if not _run_cs2_dumper(tmp): + return None, None, None -def _validate(offsets: dict, client: dict, buttons: dict) -> bool: - """Return True if extract_offsets succeeds on the given data.""" - # Import here to avoid circular dependency at module load time. - from classes.utility import Utility - return Utility.extract_offsets(offsets, client, buttons) is not None + return _load_output(tmp) def fetch_latest_release(repo: str) -> "dict | None": - """ - Fetch the latest release metadata from the GitHub Releases API. - - Returns a dict with version, download_url, html_url, changelog, is_prerelease, - or None on any network / parse failure. - """ + """Fetch the latest VioletWing release metadata from the GitHub Releases API.""" url = _GITHUB_RELEASES_URL.format(repo=repo) try: resp = requests.get( @@ -223,10 +81,10 @@ def fetch_latest_release(repo: str) -> "dict | None": logger.debug("Latest GitHub release: %s (prerelease=%s)", tag, data.get("prerelease")) return { - "version": tag, - "download_url": download_url, - "html_url": data.get("html_url", ""), - "changelog": data.get("body", ""), + "version": tag, + "download_url": download_url, + "html_url": data.get("html_url", ""), + "changelog": data.get("body", ""), "is_prerelease": bool(data.get("prerelease", False)), } @@ -237,39 +95,169 @@ def fetch_latest_release(repo: str) -> "dict | None": logger.exception("Unexpected error fetching GitHub release.") return None +# Binary management +def _ensure_binary() -> bool: + """Return True if the cs2-dumper binary is ready to use. + + Downloads from GitHub Releases if not already cached. """ - Check the Vercel status endpoint for a newer release. + if _CS2_DUMPER_EXE_PATH.exists(): + return True + return _download_cs2_dumper() - Returns (download_url, is_prerelease) or (None, False). +def _download_cs2_dumper() -> bool: + """Download the latest cs2-dumper.exe from GitHub Releases. + + Caches it at _CS2_DUMPER_EXE_PATH. Returns True on success. """ + api_url = _GITHUB_RELEASES_URL.format(repo=_CS2_DUMPER_REPO) try: - resp = requests.get(_STATUS_URL, timeout=10) + logger.info("Fetching cs2-dumper release info from %s", api_url) + resp = requests.get( + api_url, + timeout=15, + headers={"Accept": "application/vnd.github+json"}, + ) resp.raise_for_status() - data: dict = orjson.loads(resp.content) + data = orjson.loads(resp.content) - remote_str = data.get("version") - download_url = data.get("download_url") or None + download_url: str | None = None + for asset in data.get("assets", []): + if asset.get("name", "").lower() == _CS2_DUMPER_EXE_NAME.lower(): + download_url = asset["browser_download_url"] + break - if not remote_str: - logger.warning("status.json is missing 'version' field.") - return None, False + if not download_url: + logger.error( + "cs2-dumper release '%s' has no asset named '%s'. Available: %s", + data.get("tag_name"), + _CS2_DUMPER_EXE_NAME, + [a["name"] for a in data.get("assets", [])], + ) + Logger.error_code(EC.E4014) + return False + + logger.info("Downloading cs2-dumper %s from %s", data.get("tag_name"), download_url) + binary_resp = requests.get(download_url, timeout=60) + binary_resp.raise_for_status() + + _CS2_DUMPER_EXE_PATH.parent.mkdir(parents=True, exist_ok=True) + _CS2_DUMPER_EXE_PATH.write_bytes(binary_resp.content) + logger.info( + "cs2-dumper saved to %s (%d bytes).", + _CS2_DUMPER_EXE_PATH, + len(binary_resp.content), + ) + return True - try: - remote = version.parse(remote_str) - except version.InvalidVersion: - logger.warning("Invalid remote version format: %s", remote_str) - return None, False + except requests.exceptions.RequestException as exc: + logger.error("Network error downloading cs2-dumper: %s", exc) + Logger.error_code(EC.E4014) + return False + except Exception: + logger.exception("Unexpected error downloading cs2-dumper.") + Logger.error_code(EC.E4014) + return False - if remote > version.parse(current_version): - logger.info("New version available: %s", remote_str) - return download_url, False +# Subprocess +def _run_cs2_dumper(output_dir: str) -> bool: + """Spawn cs2-dumper and wait for it to finish. - logger.info("No new updates available.") - return None, False + Passes -f json so only JSON files are generated -- skipping cs/hpp/rs/zig + cuts runtime noticeably. Returns True on clean exit (returncode 0). + """ + cmd = [str(_CS2_DUMPER_EXE_PATH), "-o", output_dir, "-f", "json"] + logger.debug("Running cs2-dumper: %s", " ".join(cmd)) - except requests.exceptions.RequestException as exc: - Logger.error_code(EC.E4010, "%s", exc) - return None, False - except Exception: - logger.exception("Unexpected error during update check.") - return None, False \ No newline at end of file + startupinfo = None + creationflags = 0 + if sys.platform == "win32": + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= getattr(subprocess, "STARTF_USESHOWWINDOW", 0) + startupinfo.wShowWindow = getattr(subprocess, "SW_HIDE", 0) + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + + proc = None + stdout = "" + stderr = "" + + try: + proc = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + startupinfo=startupinfo, + creationflags=creationflags, + ) + stdout, stderr = proc.communicate(timeout=_SUBPROCESS_TIMEOUT) + except subprocess.TimeoutExpired: + if proc is not None and proc.poll() is None: + proc.kill() + stdout, stderr = proc.communicate() + Logger.error_code(EC.E4013, "cs2-dumper timed out after %ds", _SUBPROCESS_TIMEOUT) + return False + except FileNotFoundError: + Logger.error_code(EC.E4014, "Could not execute: %s", _CS2_DUMPER_EXE_PATH) + return False + except Exception as exc: + Logger.error_code(EC.E4013, "%s", exc) + return False + + if stdout: + for line in stdout.splitlines(): + logger.debug("[cs2-dumper] %s", line) + + if proc.returncode != 0: + if stderr: + for line in stderr.splitlines(): + logger.error("[cs2-dumper stderr] %s", line) + Logger.error_code(EC.E4013, "exit code %d", proc.returncode) + return False + + return True + +# Output parsing +def _load_output(output_dir: str) -> tuple[dict, dict, dict] | tuple[None, None, None]: + """Read and validate the three JSON files cs2-dumper writes. + + cs2-dumper writes: + offsets.json -- {module: {offset_name: int}} + buttons.json -- {button_name: int} (flat, no module namespace) + client_dll.json -- {client.dll: {classes: {...}}} (slugified from client.dll) + + buttons.json is flat; extract_offsets() expects {"client.dll": {...}}. + We wrap it here at the load boundary so the rest of the codebase is unaffected. + """ + tmp = Path(output_dir) + offsets_file = tmp / "offsets.json" + client_file = tmp / "client_dll.json" + buttons_file = tmp / "buttons.json" + + missing = [f.name for f in [offsets_file, client_file, buttons_file] if not f.exists()] + if missing: + Logger.error_code(EC.E4015, "Missing output files: %s", ", ".join(missing)) + return None, None, None + + try: + offsets = orjson.loads(offsets_file.read_bytes()) + client = orjson.loads(client_file.read_bytes()) + buttons_raw = orjson.loads(buttons_file.read_bytes()) + except (orjson.JSONDecodeError, IOError) as exc: + Logger.error_code(EC.E4015, "JSON read error: %s", exc) + return None, None, None + + # cs2-dumper buttons are flat {name: int}; wrap to match extract_offsets() contract. + buttons = buttons_raw + + if not _validate(offsets, client, buttons): + Logger.error_code(EC.E4015, "Validation failed") + return None, None, None + + logger.info("cs2-dumper: offsets loaded and validated successfully.") + return offsets, client, buttons + +def _validate(offsets: dict, client: dict, buttons: dict) -> bool: + from classes.utility import Utility + return Utility.extract_offsets(offsets, client, buttons) is not None \ No newline at end of file diff --git a/classes/process_monitor.py b/classes/process_monitor.py index 69c4b80..cd22ba0 100644 --- a/classes/process_monitor.py +++ b/classes/process_monitor.py @@ -5,7 +5,7 @@ import psutil -# Matches the constant in game_process.py — defined here directly to avoid +# Matches the constant in game_process.py - defined here directly to avoid # pulling in pygetwindow, which is Windows-only and not needed by this module. _CS2_PROCESS = "cs2.exe" diff --git a/classes/profile_manager.py b/classes/profile_manager.py index b1c002e..2f88daf 100644 --- a/classes/profile_manager.py +++ b/classes/profile_manager.py @@ -11,12 +11,15 @@ logger = Logger.get_logger(__name__) -# Keys excluded from profiles — they are identity/env-specific, not playstyle. +# Keys excluded from profiles - they are identity/env-specific, not playstyle. _EXCLUDED_TOP_LEVEL = {"user_id", "seen_changelog_version", "GitHub"} # Inside General, these are env-specific and should not roam with a profile. -_EXCLUDED_GENERAL = {"OffsetSource", "OffsetsFile", "ClientDLLFile", "ButtonsFile"} +# Empty since the legacy offset path keys were removed in the live dump redesign. +# Preserved as a set so future env-specific General keys can be added here without +# touching save_profile / load_profile logic. +_EXCLUDED_GENERAL: set[str] = set() -# Only alphanumeric, spaces, dashes, underscores — avoids path traversal entirely. +# Only alphanumeric, spaces, dashes, underscores - avoids path traversal entirely. # Allows letters, digits, underscores, single spaces, and hyphens only. # Literal space (not \s) intentionally excludes tabs and newlines. _VALID_NAME_RE = re.compile(r'^[\w \-]{1,64}$') diff --git a/classes/trigger_bot.py b/classes/trigger_bot.py index 3c6ed84..dbea4d9 100644 --- a/classes/trigger_bot.py +++ b/classes/trigger_bot.py @@ -66,7 +66,7 @@ def update_config(self, config: dict) -> None: def start(self) -> None: self.is_running = True - # Clear here — the only correct place — so stop() can always set it. + # Clear here - the only correct place - so stop() can always set it. self.stop_event.clear() if self._mouse_listener is None or not self._mouse_listener.running: diff --git a/classes/updater.py b/classes/updater.py index 75c792b..fe2c367 100644 --- a/classes/updater.py +++ b/classes/updater.py @@ -159,14 +159,14 @@ def _worker(self) -> None: _d, _t = downloaded, total_bytes self._root.after(0, lambda d=_d, t=_t: self._update_progress(d, t)) - logger.info("Update downloaded — writing updater script.") + logger.info("Update downloaded - writing updater script.") logger.info("bat_file path: %s", bat_file) logger.info("current_exe: %s", current_exe) logger.info("temp_exe: %s", temp_exe) # Batch files on Windows require CRLF line endings. # Unix newlines (\n only) cause `goto` label resolution to fail - # silently — the script runs but jumps never land. + # silently - the script runs but jumps never land. bat_content = "\r\n".join([ "@echo off", "title VioletWing Updater", @@ -216,7 +216,7 @@ def _worker(self) -> None: ["cmd.exe", "/c", bat_file], creationflags=subprocess.CREATE_NEW_CONSOLE, ) - logger.info("Updater process launched — pid %d", proc.pid) + logger.info("Updater process launched - pid %d", proc.pid) self._root.after(0, lambda: self._finish(success=True, exc=None)) except Exception as exc: @@ -247,7 +247,7 @@ def fetch_in_background(self, on_complete: callable) -> None: def _fetch_worker(self, on_complete: callable) -> None: if not getattr(sys, "frozen", False): - logger.info("Running from source — update download disabled; changelog check proceeds.") + logger.info("Running from source - update download disabled; changelog check proceeds.") logger.info("Fetching latest release from GitHub (%s)...", _GITHUB_REPO) release = fetch_latest_release(_GITHUB_REPO) diff --git a/classes/utility.py b/classes/utility.py index 86f7d70..e737f2e 100644 --- a/classes/utility.py +++ b/classes/utility.py @@ -28,22 +28,10 @@ def is_game_active() -> bool: def is_game_running() -> bool: return _gp.is_game_running() - @staticmethod - def load_offset_sources() -> dict: - return _of.load_offset_sources() - @staticmethod def fetch_offsets(): return _of.fetch_offsets() - @staticmethod - def get_available_offset_sources() -> list[dict]: - return _of.get_available_offset_sources() - - @staticmethod - def check_for_updates(current_version: str) -> tuple: - return _of.check_for_updates(current_version) - @staticmethod def get_vk_code(key: str) -> int: return _get_vk_code(key) @@ -91,13 +79,22 @@ def extract_offsets(offsets: dict, client_data: dict, buttons_data: dict) -> dic buttons = buttons_data.get("client.dll", {}) classes = client_data.get("client.dll", {}).get("classes", {}) + def _resolve_field_value(raw): + """Extract an integer offset from either format: + - a2x: field value is a bare int + - OffsetFetcher: field value is {"type": "...", "offset": int} + """ + if isinstance(raw, dict): + return raw.get("offset") + return raw + def get_field(class_name: str, field_name: str): class_info = classes.get(class_name) if not class_info: raise KeyError(f"Class '{class_name}' not found") - field = class_info.get("fields", {}).get(field_name) - if field is not None: - return field + raw = class_info.get("fields", {}).get(field_name) + if raw is not None: + return _resolve_field_value(raw) parent = class_info.get("parent") if parent: return get_field(parent, field_name) @@ -108,7 +105,7 @@ def get_field(class_name: str, field_name: str): "dwLocalPlayerPawn": client.get("dwLocalPlayerPawn"), "dwLocalPlayerController": client.get("dwLocalPlayerController"), "dwViewMatrix": client.get("dwViewMatrix"), - "dwForceJump": buttons.get("jump"), + "jump": buttons.get("jump"), "m_iHealth": get_field("C_BaseEntity", "m_iHealth"), "m_iTeamNum": get_field("C_BaseEntity", "m_iTeamNum"), "m_pGameSceneNode": get_field("C_BaseEntity", "m_pGameSceneNode"), diff --git a/gui/changelog_window.py b/gui/changelog_window.py index 57b9939..688d0f7 100644 --- a/gui/changelog_window.py +++ b/gui/changelog_window.py @@ -32,9 +32,9 @@ _BADGE_LINE_RE = re.compile( r"^\s*" r"(" - r"(\[!\[.*?\]\(.*?\)\]\(.*?\))" # [![alt](img)](link) — linked badge - r"|(\[!\[.*?\]\(.*?\)\])" # [![alt](img)] — unlinked badge - r"|(!\\[.*?\\]\\(.*?\\))" # ![alt](url) — bare image/badge + r"(\[!\[.*?\]\(.*?\)\]\(.*?\))" # [![alt](img)](link) - linked badge + r"|(\[!\[.*?\]\(.*?\)\])" # [![alt](img)] - unlinked badge + r"|(!\\[.*?\\]\\(.*?\\))" # ![alt](url) - bare image/badge r"|(https?://\\S+)" # bare URL r")" r"\s*$" @@ -237,7 +237,7 @@ def _configure_tags(self, bg: str, fg: str) -> None: mono = "JetBrainsMono" if "JetBrainsMono" in tkfont.families() else "Courier" ui = FONT_FAMILY_BOLD[0] - # Outfit is a proportional sans-serif — much more readable for prose than JetBrainsMono + # Outfit is a proportional sans-serif - much more readable for prose than JetBrainsMono body = FONT_FAMILY_BOLD[0] self._text.configure(bg=bg, fg=fg, insertbackground=fg, font=(body, 14)) @@ -252,7 +252,7 @@ def _configure_tags(self, bg: str, fg: str) -> None: self._text.tag_configure("bold", font=(ui, 14, "bold")) self._text.tag_configure("code", font=(mono, 13), background=subtle, foreground=fg) - # Inline PR/issue number — muted violet, no underline + # Inline PR/issue number - muted violet, no underline self._text.tag_configure("pr_num", font=(ui, 14, "bold"), foreground="#7c6fa0") # Divider @@ -383,7 +383,7 @@ def _insert_inline(self, text: str, base: tuple) -> None: # PR/issue short form produced by _strip_inline_noise self._text.insert("end", raw, ("pr_num",)) else: - # Markdown link — render display text only, URL discarded + # Markdown link - render display text only, URL discarded self._text.insert("end", m.group(4), base) cursor = m.end() if cursor < len(text): diff --git a/gui/components.py b/gui/components.py index 1af963a..edad135 100644 --- a/gui/components.py +++ b/gui/components.py @@ -43,7 +43,7 @@ def create_section_header(parent, title, subtitle, icon_file=None) -> ctk.CTkFra def build_item_scaffold(parent, label_text, description, is_last=False) -> ctk.CTkFrame: """Build the standard setting-item card (label on left, widget slot on right). - Bottom padding is 40 px for the last item in a section, 30 px otherwise — + Bottom padding is 40 px for the last item in a section, 30 px otherwise - matching the visual rhythm used across all settings tabs. Returns the right-side widget frame for the caller to populate. diff --git a/gui/faq_tab.py b/gui/faq_tab.py index 5a0c1f9..382054c 100644 --- a/gui/faq_tab.py +++ b/gui/faq_tab.py @@ -54,7 +54,7 @@ def _render_error_reference(container) -> None: cat_row.pack(fill="x", padx=30, pady=(10, 4)) ctk.CTkLabel( cat_row, - text=f"— {cat_label}", + text=f"- {cat_label}", font=FONT_WIDGET, text_color=COLOR_ACCENT_FG, anchor="w", @@ -272,7 +272,7 @@ def populate_faq(main_window, frame): is_last = (i == len(faqs) - 1) create_faq_card(faq_container, i + 1, question, answer, is_last) - # Error Reference section — rendered from the live catalog so it + # Error Reference section - rendered from the live catalog so it # stays in sync with the code automatically. _render_error_reference(faq_container) diff --git a/gui/general_settings_tab.py b/gui/general_settings_tab.py index f8c2475..240a9b0 100644 --- a/gui/general_settings_tab.py +++ b/gui/general_settings_tab.py @@ -1,39 +1,28 @@ import customtkinter as ctk from gui.icon_loader import icon_label, load_icon -import os -from pathlib import Path -from tkinter import filedialog from classes.config_manager import ConfigManager -from classes.utility import Utility import classes.profile_manager as ProfileManager from gui.components import create_section_frame, create_section_header, build_item_scaffold from gui.modal import AppModal from gui.theme import ( FONT_TITLE, FONT_SUBTITLE, FONT_ITEM_LABEL, FONT_ITEM_DESCRIPTION, FONT_WIDGET, - COLOR_TEXT_PRIMARY, COLOR_TEXT_SECONDARY, COLOR_ACCENT_FG, COLOR_ACCENT_HOVER, - SETTING_ITEM_STYLE, CHECKBOX_STYLE, COMBOBOX_STYLE, + COLOR_TEXT_PRIMARY, COLOR_TEXT_SECONDARY, + CHECKBOX_STYLE, COMBOBOX_STYLE, BUTTON_STYLE_PRIMARY, BUTTON_STYLE_DANGER, ) FEATURE_SETTINGS = [ - ("Enable Trigger", "checkbox", "Trigger", "Toggle the trigger bot feature"), - ("Enable Overlay", "checkbox", "Overlay", "Toggle the ESP overlay feature"), - ("Enable Bunnyhop", "checkbox", "Bunnyhop", "Toggle the bunnyhop feature"), - ("Enable Noflash", "checkbox", "Noflash", "Toggle the noflash feature"), + ("Enable Trigger", "checkbox", "Trigger", "Toggle the trigger bot feature"), + ("Enable Overlay", "checkbox", "Overlay", "Toggle the ESP overlay feature"), + ("Enable Bunnyhop", "checkbox", "Bunnyhop", "Toggle the bunnyhop feature"), + ("Enable Noflash", "checkbox", "Noflash", "Toggle the noflash feature"), ] PROGRAM_SETTINGS = [ - ("Detailed Logs", "checkbox", "DetailedLogs", "Show verbose debug log instead of the standard log"), - ("Enable Disguise", "checkbox", "Disguise", "Disguise the program as another app on next startup"), + ("Detailed Logs", "checkbox", "DetailedLogs", "Show verbose debug log instead of the standard log"), + ("Enable Disguise", "checkbox", "Disguise", "Disguise the program as another app on next startup"), ] -OFFSET_FILES = [ - ("Offsets File", "offsets.json", "Select offsets.json file", "OffsetsFile"), - ("Client DLL File", "client.dll.json", "Select client.dll.json file", "ClientDLLFile"), - ("Buttons File", "buttons.json", "Select buttons.json file", "ButtonsFile"), -] - - def populate_general_settings(main_window, frame): settings = ctk.CTkScrollableFrame(frame, fg_color="transparent") settings.pack(fill="both", expand=True, padx=40, pady=40) @@ -48,33 +37,27 @@ def populate_general_settings(main_window, frame): font=FONT_SUBTITLE, text_color=COLOR_TEXT_SECONDARY, anchor="w").pack(side="left", padx=(20, 0), pady=(10, 0)) - create_features_section(main_window, settings) - create_program_section(main_window, settings) - create_offsets_section(main_window, settings) - create_reset_section(main_window, settings) + _create_features_section(main_window, settings) + _create_program_section(main_window, settings) + _create_reset_section(main_window, settings) -def create_features_section(main_window, parent): +def _create_features_section(main_window, parent): section = create_section_frame(parent) create_section_header(section, "Feature Configuration", "Enable or disable main application features", icon_file="sliders_icon.png") for i, (label, widget, key, desc) in enumerate(FEATURE_SETTINGS): - _create_setting_item( - section, label, desc, widget, key, main_window, - is_last=(i == len(FEATURE_SETTINGS) - 1), - ) + _create_checkbox_item(section, label, desc, key, main_window, + is_last=(i == len(FEATURE_SETTINGS) - 1)) -def create_program_section(main_window, parent): +def _create_program_section(main_window, parent): section = create_section_frame(parent) create_section_header(section, "Program Settings", "Configure core program behaviour and disguise", icon_file="user_secret_icon.png") - for i, (label, widget, key, desc) in enumerate(PROGRAM_SETTINGS): - _create_setting_item( - section, label, desc, widget, key, main_window, - is_last=False, - ) + _create_checkbox_item(section, label, desc, key, main_window, + is_last=(i == len(PROGRAM_SETTINGS) - 1)) wf = build_item_scaffold(section, "Active Profile", "The program this instance is currently disguised as.", @@ -84,50 +67,7 @@ def create_program_section(main_window, parent): ctk.CTkLabel(wf, text=disguise_name, font=FONT_WIDGET, text_color=color, fg_color="transparent").pack() -def create_offsets_section(main_window, parent): - section = create_section_frame(parent) - header = create_section_header(section, "Offsets Configuration", - "Configure offset source and local files", - icon_file="satellite_dish_icon.png") - - available = Utility.load_offset_sources() - source_mapping = {f"{cfg['name']} ({cfg['author']})": sid for sid, cfg in available.items()} - source_mapping["Local Files"] = "local" - main_window.offset_source_mapping = source_mapping - - current_src = main_window.triggerbot.config["General"].get("OffsetSource", "a2x") - current_display = next( - (name for name, sid in source_mapping.items() if sid == current_src), "Local Files" - ) - - offset_source_var = ctk.StringVar(value=current_display) - ctk.CTkOptionMenu( - header, - variable=offset_source_var, - values=list(source_mapping.keys()), - command=lambda dn: _update_offset_source(main_window, source_mapping[dn]), - **COMBOBOX_STYLE, - ).pack(side="right", padx=(0, 10)) - main_window.ui_bridge.register("OffsetSource", var=offset_source_var) - - main_window.offset_source_notice = ctk.CTkLabel( - section, - text="", - font=FONT_ITEM_DESCRIPTION, - text_color="#f59e0b", - anchor="w", - ) - - main_window.local_files_frame = ctk.CTkFrame(section, fg_color="transparent") - if current_src == "local": - main_window.local_files_frame.pack(fill="x", padx=40, pady=(0, 40)) - - main_window.local_file_paths = {} - for label, filename, desc, config_key in OFFSET_FILES: - _create_file_selector(main_window, main_window.local_files_frame, - label, filename, desc, config_key) - -def create_reset_section(main_window, parent): +def _create_reset_section(main_window, parent): section = create_section_frame(parent) create_section_header(section, "Configuration Management", "Manage configuration files, settings, and profiles", @@ -166,7 +106,7 @@ def _create_profile_row(main_window, section): profile_frame = ctk.CTkFrame(outer, fg_color="transparent") profile_frame.pack(fill="x") - # Row B — inline name-entry, hidden until "Save As Profile" is clicked + # Row B - inline name-entry, hidden until "Save As Profile" is clicked input_frame = ctk.CTkFrame(outer, fg_color="transparent") # not packed yet; revealed by _toggle_save_input @@ -195,8 +135,7 @@ def _confirm_save(event=None): name = name.strip() if name in ProfileManager.list_profiles(): if not AppModal.confirm( - main_window.root, - "Overwrite Profile", + main_window.root, "Overwrite Profile", f"A profile named '{name}' already exists. Overwrite it?", ): return @@ -205,74 +144,45 @@ def _confirm_save(event=None): input_frame.pack_forget() main_window.refresh_profile_dropdown() else: - AppModal.error( - main_window.root, - "Save Failed", - f"Could not save profile '{name}'. Check logs." - ) + AppModal.error(main_window.root, "Save Failed", + f"Could not save profile '{name}'. Check logs.") name_entry.bind("", _confirm_save) name_entry.bind("", lambda e: input_frame.pack_forget()) _confirm_icon = load_icon("play_icon.png", size=(16, 16)) - ctk.CTkButton( - input_frame, - text="Confirm", - image=_confirm_icon, - compound="left", - width=130, - command=_confirm_save, - **BUTTON_STYLE_PRIMARY, - ).pack(side="left", padx=(0, 8)) + ctk.CTkButton(input_frame, text="Confirm", image=_confirm_icon, compound="left", + width=130, command=_confirm_save, **BUTTON_STYLE_PRIMARY).pack( + side="left", padx=(0, 8)) _cancel_icon = load_icon("xmark_icon.png", size=(16, 16)) - ctk.CTkButton( - input_frame, - text="Cancel", - image=_cancel_icon, - compound="left", - width=110, - command=lambda: input_frame.pack_forget(), - **BUTTON_STYLE_DANGER, - ).pack(side="left") + ctk.CTkButton(input_frame, text="Cancel", image=_cancel_icon, compound="left", + width=110, command=lambda: input_frame.pack_forget(), + **BUTTON_STYLE_DANGER).pack(side="left") # Row A contents _save_icon = load_icon("box_archive_icon.png", size=(16, 16)) - ctk.CTkButton( - profile_frame, - text="Save As Profile", - image=_save_icon, - compound="left", - width=200, - command=_toggle_save_input, - **BUTTON_STYLE_PRIMARY, - ).pack(side="left", padx=(0, 16)) + ctk.CTkButton(profile_frame, text="Save As Profile", image=_save_icon, + compound="left", width=200, command=_toggle_save_input, + **BUTTON_STYLE_PRIMARY).pack(side="left", padx=(0, 16)) # Profile selector dropdown profiles = ProfileManager.list_profiles() - display = profiles if profiles else ["No profiles"] + display = profiles if profiles else ["No profiles"] profile_var = ctk.StringVar(value=display[0]) dropdown = ctk.CTkOptionMenu( - profile_frame, - variable=profile_var, - values=display, - width=200, + profile_frame, variable=profile_var, values=display, width=200, **{k: v for k, v in COMBOBOX_STYLE.items() if k != "width"}, ) dropdown.pack(side="left", padx=(0, 12)) - # Store refs on main_window so refresh_profile_dropdown can update the widget - main_window._profile_var = profile_var + main_window._profile_var = profile_var main_window._profile_dropdown = dropdown - # Active profile indicator — updated by main_window.update_active_profile_label() + # Active profile indicator - updated by main_window.update_active_profile_label() active_label = ctk.CTkLabel( - profile_frame, - text="", - font=FONT_ITEM_DESCRIPTION, - text_color=COLOR_TEXT_SECONDARY, - fg_color="transparent", - anchor="w", + profile_frame, text="", font=FONT_ITEM_DESCRIPTION, + text_color=COLOR_TEXT_SECONDARY, fg_color="transparent", anchor="w", ) active_label.pack(side="left", padx=(0, 12)) main_window._active_profile_label = active_label @@ -281,27 +191,17 @@ def _confirm_save(event=None): # Load Profile _load_icon = load_icon("rotate_icon.png", size=(16, 16)) - ctk.CTkButton( - profile_frame, - text="Load Profile", - image=_load_icon, - compound="left", - width=160, - command=lambda: _load_selected_profile(main_window), - **BUTTON_STYLE_PRIMARY, - ).pack(side="left", padx=(0, 12)) + ctk.CTkButton(profile_frame, text="Load Profile", image=_load_icon, + compound="left", width=160, + command=lambda: _load_selected_profile(main_window), + **BUTTON_STYLE_PRIMARY).pack(side="left", padx=(0, 12)) # Delete Profile _del_icon = load_icon("circle_xmark_icon.png", size=(16, 16)) - ctk.CTkButton( - profile_frame, - text="Delete Profile", - image=_del_icon, - compound="left", - width=160, - command=lambda: _delete_selected_profile(main_window), - **BUTTON_STYLE_DANGER, - ).pack(side="left") + ctk.CTkButton(profile_frame, text="Delete Profile", image=_del_icon, + compound="left", width=160, + command=lambda: _delete_selected_profile(main_window), + **BUTTON_STYLE_DANGER).pack(side="left") def _load_selected_profile(main_window) -> None: name = main_window._profile_var.get() @@ -310,119 +210,25 @@ def _load_selected_profile(main_window) -> None: return main_window.load_profile(name) - def _delete_selected_profile(main_window) -> None: name = main_window._profile_var.get() if not name or name == "No profiles": AppModal.warning(main_window.root, "No Profile", "No profile selected.") return - if not AppModal.confirm( - main_window.root, - "Delete Profile", - f"Delete profile '{name}'? This cannot be undone.", - ): + if not AppModal.confirm(main_window.root, "Delete Profile", + f"Delete profile '{name}'? This cannot be undone."): return ok = main_window.delete_profile(name) if ok: main_window.refresh_profile_dropdown() else: - AppModal.error( - main_window.root, - "Delete Failed", - f"Could not delete profile '{name}'. Check logs." - ) - -def _create_setting_item(parent, label_text, description, widget_type, key, - main_window, is_last=False): - wf = build_item_scaffold(parent, label_text, description, is_last) - if widget_type == "checkbox": - var = ctk.BooleanVar(value=main_window.triggerbot.config["General"].get(key, False)) - ctk.CTkCheckBox(wf, text="", variable=var, - command=lambda: main_window.save_settings(show_message=False), - **CHECKBOX_STYLE).pack() - main_window.ui_bridge.register(key, var=var) - else: - raise ValueError(f"Unsupported widget type: {widget_type}") - -def _create_file_selector(main_window, parent, label_text, filename, description, config_key): - item_frame = ctk.CTkFrame(parent, fg_color="transparent") - item_frame.pack(fill="x", pady=(0, 10)) - container = ctk.CTkFrame(item_frame, **SETTING_ITEM_STYLE) - container.pack(fill="x") - content = ctk.CTkFrame(container, fg_color="transparent") - content.pack(fill="x", padx=25, pady=15) - - lf = ctk.CTkFrame(content, fg_color="transparent") - lf.pack(side="left", fill="x", expand=True) - ctk.CTkLabel(lf, text=label_text, font=FONT_ITEM_LABEL, - text_color=COLOR_TEXT_PRIMARY, anchor="w").pack(fill="x", pady=(0, 4)) - ctk.CTkLabel(lf, text=description, font=FONT_ITEM_DESCRIPTION, - text_color=COLOR_TEXT_SECONDARY, anchor="w", wraplength=400).pack(fill="x") - - wf = ctk.CTkFrame(content, fg_color="transparent") - wf.pack(side="right", padx=(30, 0)) - - def select_file(): - path = filedialog.askopenfilename( - filetypes=[("JSON files", "*.json")], - initialdir=Path(ConfigManager.CONFIG_DIRECTORY), - ) - if path: - main_window.local_file_paths[filename] = path - btn.configure(text=f"Selected: {os.path.basename(path)}") - main_window.triggerbot.config["General"][config_key] = path - main_window.save_settings(show_message=False) - _update_offset_source(main_window, "local") - - current = main_window.triggerbot.config["General"].get(config_key, "") - btn_text = ( - f"Selected: {os.path.basename(current)}" - if current and os.path.exists(current) - else f"Select {filename}" - ) - btn = ctk.CTkButton(wf, text=btn_text, font=FONT_WIDGET, corner_radius=10, - fg_color=COLOR_ACCENT_FG, hover_color=COLOR_ACCENT_HOVER, - command=select_file) - btn.pack() - -def _update_offset_source(main_window, selected_id: str) -> None: - main_window.triggerbot.config["General"]["OffsetSource"] = selected_id - ConfigManager.save_config(main_window.triggerbot.config, log_info=False) - - if selected_id == "local": - main_window.local_files_frame.pack(fill="x", padx=40, pady=(0, 40)) - else: - main_window.local_files_frame.pack_forget() + AppModal.error(main_window.root, "Delete Failed", + f"Could not delete profile '{name}'. Check logs.") - client_running = any( - getattr(fd["instance"], "is_running", False) - for fd in main_window.features.values() - ) - notice = getattr(main_window, "offset_source_notice", None) - - if client_running: - if notice is not None: - notice.configure(text="Offset source will apply after stopping the client.") - notice.pack(anchor="w", padx=40, pady=(0, 16)) - return - - if selected_id == "local": - cfg = main_window.triggerbot.config.get("General", {}) - config_dir = Path(ConfigManager.CONFIG_DIRECTORY) - local_paths = [ - Path(cfg.get("OffsetsFile", config_dir / "offsets.json")), - Path(cfg.get("ClientDLLFile", config_dir / "client_dll.json")), - Path(cfg.get("ButtonsFile", config_dir / "buttons.json")), - ] - missing = [p.name for p in local_paths if not p.exists()] - if missing and notice is not None: - notice.configure( - text=f"Missing: {', '.join(missing)} — select the files below before reloading." - ) - notice.pack(anchor="w", padx=40, pady=(0, 16)) - return - - if notice is not None: - notice.pack_forget() - notice.configure(text="") - main_window.fetch_offsets_async() \ No newline at end of file +def _create_checkbox_item(parent, label_text, description, key, main_window, is_last=False): + wf = build_item_scaffold(parent, label_text, description, is_last) + var = ctk.BooleanVar(value=main_window.triggerbot.config["General"].get(key, False)) + ctk.CTkCheckBox(wf, text="", variable=var, + command=lambda: main_window.save_settings(show_message=False), + **CHECKBOX_STYLE).pack() + main_window.ui_bridge.register(key, var=var) \ No newline at end of file diff --git a/gui/home_tab.py b/gui/home_tab.py index 4a563dc..706a19a 100644 --- a/gui/home_tab.py +++ b/gui/home_tab.py @@ -2,16 +2,12 @@ import threading import orjson import requests -import time from datetime import datetime from pathlib import Path -from PIL import Image from gui.icon_loader import icon_label, load_icon from classes.logger import Logger -from classes.utility import Utility from classes.config_manager import ConfigManager from classes.process_monitor import ProcessMonitor -from dateutil.parser import parse as parse_date from gui.theme import ( FONT_TITLE, FONT_SUBTITLE, FONT_SECTION_TITLE, FONT_SECTION_DESCRIPTION, FONT_ITEM_LABEL, FONT_ITEM_DESCRIPTION, FONT_WIDGET, @@ -20,16 +16,12 @@ SECTION_STYLE, BUTTON_STYLE_PRIMARY, BUTTON_STYLE_DANGER, ) -# Days-stale thresholds for the Offsets card warning colours. -_STALENESS_AMBER_DAYS = 1 -_STALENESS_RED_DAYS = 3 +# cs2-dumper binary lives in the config directory. +_CS2_DUMPER_EXE_NAME = "cs2-dumper.exe" +_CS2_DUMPER_REPO = "a2x/cs2-dumper" -_COLOR_STALE_AMBER = "#f59e0b" -_COLOR_STALE_RED = "#ef4444" -_COLOR_FRESH = "#22c55e" - -# Guards concurrent writes to main_window._cs2_patch_dt / ._offsets_dt. -_staleness_lock = threading.Lock() +_COLOR_OK = "#22c55e" +_COLOR_ERR = "#ef4444" logger = Logger.get_logger(__name__) @@ -48,6 +40,7 @@ def populate_dashboard(main_window, frame): font=FONT_SUBTITLE, text_color=COLOR_TEXT_SECONDARY).pack( side="left", padx=(20, 0), pady=(10, 0)) + # Stats banner stats_banner = ctk.CTkFrame(dashboard, **SECTION_STYLE) stats_banner.pack(fill="x", pady=(0, 30)) sb_content = ctk.CTkFrame(stats_banner, fg_color="transparent") @@ -57,14 +50,9 @@ def populate_dashboard(main_window, frame): sb_content, "CS2 Update", "Checking...", "#6b7280", "crosshairs_icon.png") cs2_item.pack(side="left", expand=True) - upd_item, main_window.update_value_label = _stat_banner_item( - sb_content, "Offsets Update", "Checking...", "#6b7280", "rotate_icon.png") - upd_item.pack(side="left", expand=True) - - # Warning label hidden until staleness is detected - main_window._offsets_warning_label = ctk.CTkLabel( - upd_item, text="", font=FONT_ITEM_DESCRIPTION, - text_color=_COLOR_STALE_AMBER, anchor="w") + dumper_item, main_window.dumper_version_label = _stat_banner_item( + sb_content, "cs2-dumper", "Checking...", "#6b7280", "rotate_icon.png") + dumper_item.pack(side="left", expand=True) ver_item, _ = _stat_banner_item( sb_content, "Version", ConfigManager.VERSION, "#8e44ad", "box_archive_icon.png") @@ -105,44 +93,28 @@ def _sysmon_row(parent, icon_file, label_text, is_last=False): icon_label(row, icon_file, size=(18, 18), padx=(0, 12)) ctk.CTkLabel(row, text=label_text, font=FONT_ITEM_LABEL, text_color=COLOR_TEXT_SECONDARY, anchor="w", width=140).pack(side="left") - # Value frame: populated by _poll with inline chip labels val_frame = ctk.CTkFrame(row, fg_color="transparent") val_frame.pack(side="left") - - # Add a placeholder so it doesn't stretch to 200x200 before the first poll - ctk.CTkLabel(val_frame, text="Loading...", font=FONT_ITEM_DESCRIPTION, text_color=COLOR_TEXT_SECONDARY).pack(side="left") - + ctk.CTkLabel(val_frame, text="Loading...", font=FONT_ITEM_DESCRIPTION, + text_color=COLOR_TEXT_SECONDARY).pack(side="left") return val_frame main_window._sysmon_cs2_frame = _sysmon_row(monitor, "crosshairs_icon.png", "CS2") main_window._sysmon_self_frame = _sysmon_row(monitor, "bolt_icon.png", "VioletWing") main_window._sysmon_ram_frame = _sysmon_row(monitor, "gear_icon.png", "System RAM", is_last=True) - - - fetch_last_update(main_window) fetch_cs2_latest_patch(main_window) + fetch_cs2_dumper_version(main_window) start_process_monitor_poll(main_window) -def start_process_monitor_poll(main_window) -> None: - """Schedule a recurring 5-second poll that updates the System Monitor card. - Runs on the main thread via root.after — psutil calls for 2-3 processes - are sub-millisecond, so no background thread is needed. The after handle - is stored on main_window so cleanup() can cancel it on exit. - """ +def start_process_monitor_poll(main_window) -> None: _DOT = "·" _RAM_COLOR_OK = "#22c55e" _RAM_COLOR_AMBER = "#f59e0b" - _RAM_COLOR_RED = COLOR_BUTTON_DANGER_FG[1] # dark-mode danger red + _RAM_COLOR_RED = COLOR_BUTTON_DANGER_FG[1] def _set_chips(frame_attr: str, chips: list[tuple[str, str]]) -> None: - """Replace the children of a value frame with inline chip labels. - - chips: list of (text, color) pairs rendered left-to-right with no gap. - Existing children are destroyed on each call — frames hold only a - handful of labels so the churn is negligible. - """ def _apply(): try: frame = getattr(main_window, frame_attr, None) @@ -168,30 +140,28 @@ def _poll() -> None: cs2 = ProcessMonitor.get_cs2_stats() if cs2: _set_chips("_sysmon_cs2_frame", [ - (f"PID {cs2['pid']}", COLOR_TEXT_SECONDARY), + (f"PID {cs2['pid']}", COLOR_TEXT_SECONDARY), _dot(), - (f"{cs2['mem_mb']:.0f}", COLOR_TEXT_PRIMARY), - (" MB", COLOR_TEXT_SECONDARY), + (f"{cs2['mem_mb']:.0f}", COLOR_TEXT_PRIMARY), + (" MB", COLOR_TEXT_SECONDARY), _dot(), - (f"{cs2['cpu_percent']:.1f}%", COLOR_TEXT_PRIMARY), - (" CPU", COLOR_TEXT_SECONDARY), + (f"{cs2['cpu_percent']:.1f}%", COLOR_TEXT_PRIMARY), + (" CPU", COLOR_TEXT_SECONDARY), ]) else: - _set_chips("_sysmon_cs2_frame", [ - ("Not running", _RAM_COLOR_RED), - ]) + _set_chips("_sysmon_cs2_frame", [("Not running", _RAM_COLOR_RED)]) slf = ProcessMonitor.get_self_stats() if slf: _set_chips("_sysmon_self_frame", [ - (f"{slf['mem_mb']:.0f}", COLOR_TEXT_PRIMARY), - (" MB", COLOR_TEXT_SECONDARY), + (f"{slf['mem_mb']:.0f}", COLOR_TEXT_PRIMARY), + (" MB", COLOR_TEXT_SECONDARY), _dot(), - (f"{slf['cpu_percent']:.1f}%", COLOR_TEXT_PRIMARY), - (" CPU", COLOR_TEXT_SECONDARY), + (f"{slf['cpu_percent']:.1f}%", COLOR_TEXT_PRIMARY), + (" CPU", COLOR_TEXT_SECONDARY), ]) else: - _set_chips("_sysmon_self_frame", [("—", COLOR_TEXT_SECONDARY)]) + _set_chips("_sysmon_self_frame", [("-", COLOR_TEXT_SECONDARY)]) ram = ProcessMonitor.get_system_ram() if ram: @@ -202,239 +172,139 @@ def _poll() -> None: _RAM_COLOR_OK ) _set_chips("_sysmon_ram_frame", [ - (f"{ram['used_gb']:.1f}", COLOR_TEXT_PRIMARY), - (f" / {ram['total_gb']:.1f} GB", COLOR_TEXT_SECONDARY), + (f"{ram['used_gb']:.1f}", COLOR_TEXT_PRIMARY), + (f" / {ram['total_gb']:.1f} GB", COLOR_TEXT_SECONDARY), _dot(), - (f"{pct:.0f}%", pct_color), + (f"{pct:.0f}%", pct_color), ]) else: - _set_chips("_sysmon_ram_frame", [("—", COLOR_TEXT_SECONDARY)]) + _set_chips("_sysmon_ram_frame", [("-", COLOR_TEXT_SECONDARY)]) if main_window.root.winfo_exists(): main_window._process_monitor_timer = main_window.root.after(5000, _poll) - # Deferred so the first poll runs after __init__ sets current_view. main_window._process_monitor_timer = main_window.root.after(5000, _poll) def _stat_banner_item(parent, title, value, color, icon_file=None): item = ctk.CTkFrame(parent, fg_color="transparent") - if icon_file: icon_label(item, icon_file, size=(18, 18), padx=(0, 8)) - ctk.CTkLabel(item, text=f"{title}:", font=FONT_ITEM_LABEL, text_color=COLOR_TEXT_SECONDARY).pack(side="left", padx=(0, 6)) - val_label = ctk.CTkLabel(item, text=value, font=FONT_WIDGET, text_color=color) val_label.pack(side="left") - return item, val_label -def _check_offsets_staleness(main_window): - """ - Called (under _staleness_lock) after either date thread resolves its value. - Does nothing until both datetimes are available. - Recolours the Offsets card and appends a warning label when the patch date - is strictly newer than the offsets commit date. - """ - cs2_dt = getattr(main_window, "_cs2_patch_dt", None) - offsets_dt = getattr(main_window, "_offsets_dt", None) - if cs2_dt is None or offsets_dt is None: - return - - delta_days = (cs2_dt.date() - offsets_dt.date()).days - - if delta_days <= 0: - color = _COLOR_FRESH - warning = None - elif delta_days <= _STALENESS_AMBER_DAYS: - color = _COLOR_STALE_AMBER - warning = f"\u26a0 May be outdated ({delta_days}d behind patch)" - elif delta_days <= _STALENESS_RED_DAYS: - color = _COLOR_STALE_AMBER - warning = f"\u26a0 Likely outdated ({delta_days}d behind patch)" - else: - color = _COLOR_STALE_RED - warning = f"\u2715 Stale \u2014 {delta_days}d behind last CS2 patch" - - def _apply(): - try: - if not main_window.root.winfo_exists(): - return - if hasattr(main_window, "update_value_label"): - main_window.update_value_label.configure(text_color=color) - if hasattr(main_window, "_offsets_warning_label"): - if warning: - main_window._offsets_warning_label.configure( - text=warning, text_color=color) - main_window._offsets_warning_label.pack(side="left", padx=(8, 0)) - else: - main_window._offsets_warning_label.pack_forget() - except Exception: - pass - - main_window.ui_queue_put(_apply) - - -def fetch_last_update(main_window): - """Fetch last offset commit date in a background thread.""" - stop_event = threading.Event() - main_window._fetch_update_stop = stop_event - def _run(): - max_retries = 3 - retry_delay = 5 - cache_file = Path(ConfigManager.CONFIG_DIRECTORY) / "last_update_cache.txt" +def fetch_cs2_dumper_version(main_window) -> None: + """Show the cached cs2-dumper version if the binary exists, otherwise fetch from GitHub. - def _update_ui(text, color): - def _apply(): - try: - if main_window.root.winfo_exists() and hasattr(main_window, "update_value_label"): - main_window.update_value_label.configure(text=text, text_color=color) - except Exception: - logger.exception("update_value_label update failed") - main_window.ui_queue_put(_apply) + Two cases: + - Binary already downloaded: read the version from the GitHub Releases API and + compare against the cached filename tag (stored alongside the binary). + - Binary not yet downloaded: show "Not downloaded" in amber. - def _load_cache(): - try: - return cache_file.read_text().strip() - except FileNotFoundError: - return None + This is informational only -- the actual download happens in offset_fetcher.py + when the user clicks Start Client. + """ + stop_event = threading.Event() + main_window._fetch_dumper_stop = stop_event - def _save_cache(ts): + def _update_ui(text: str, color: str) -> None: + def _apply(): try: - cache_file.write_text(ts) - except IOError as exc: - logger.error("Failed to save update cache: %s", exc) - - cached = _load_cache() - if cached: - _update_ui(cached, "#22c55e") - - config = ConfigManager.load_config() - source = config.get("General", {}).get("OffsetSource", "a2x") - - if source == "local": - offsets_file = Path(config.get("General", {}).get("OffsetsFile", "")) - if offsets_file.exists(): - mtime = datetime.fromtimestamp(offsets_file.stat().st_mtime) - ts = mtime.strftime("%m/%d/%Y %H:%M") - _save_cache(ts) - _update_ui(ts, _COLOR_FRESH) - with _staleness_lock: - main_window._offsets_dt = mtime - _check_offsets_staleness(main_window) - else: - _update_ui("No Local File", "#ef4444") - return + if main_window.root.winfo_exists() and hasattr(main_window, "dumper_version_label"): + main_window.dumper_version_label.configure(text=text, text_color=color) + except Exception: + pass + main_window.ui_queue_put(_apply) - sources = Utility.load_offset_sources() - if source not in sources: - _update_ui("Unknown Source", "#ef4444") + def _run() -> None: + exe_path = Path(ConfigManager.CONFIG_DIRECTORY) / _CS2_DUMPER_EXE_NAME + + if not exe_path.exists(): + _update_ui("Not downloaded", "#f59e0b") return - repo = sources[source].get("repository", "a2x/cs2-dumper") - github_token = config.get("GitHub", {}).get("AccessToken") - headers = { - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "VioletWing-App", - } - if github_token: - headers["Authorization"] = f"Bearer {github_token}" - - for attempt in range(max_retries): - if stop_event.is_set(): - return - try: - resp = requests.get( - f"https://api.github.com/repos/{repo}/commits/main", - headers=headers, timeout=10) - resp.raise_for_status() - data = orjson.loads(resp.content) - commit_dt = parse_date(data["commit"]["committer"]["date"]) - ts = commit_dt.strftime("%m/%d/%Y %H:%M") - _save_cache(ts) - _update_ui(ts, _COLOR_FRESH) - with _staleness_lock: - main_window._offsets_dt = commit_dt - _check_offsets_staleness(main_window) - return - except requests.exceptions.HTTPError as exc: - if exc.response.status_code == 403 and attempt < max_retries - 1: - for _ in range(retry_delay * 10): - if stop_event.is_set(): - return - time.sleep(0.1) - continue - _update_ui("Rate Limit" if getattr(exc.response, "status_code", 0) == 403 - else "Error", "#ef4444") - return - except Exception as exc: - logger.error("Failed to fetch last update: %s", exc) - if attempt < max_retries - 1: - for _ in range(retry_delay * 10): - if stop_event.is_set(): - return - time.sleep(0.1) - continue - _update_ui("Error", "#ef4444") + # Binary present -- fetch the latest tag from GitHub so we can show the version. + # We don't force an update here; that happens on next Start Client if needed. + if stop_event.is_set(): + return + try: + resp = requests.get( + f"https://api.github.com/repos/{_CS2_DUMPER_REPO}/releases/latest", + timeout=10, + headers={"Accept": "application/vnd.github+json"}, + ) + resp.raise_for_status() + data = orjson.loads(resp.content) + tag = data.get("tag_name", "unknown") + _update_ui(tag, _COLOR_OK) + except Exception as exc: + logger.warning("Could not fetch cs2-dumper version from GitHub: %s", exc) + # Binary exists but we couldn't check version -- show generic ready state. + _update_ui("Ready", _COLOR_OK) threading.Thread(target=_run, daemon=True).start() -def fetch_cs2_latest_patch(main_window): - """Fetch latest CS2 patch date from Steam API in a background thread.""" + +def fetch_cs2_latest_patch(main_window) -> None: + """Fetch the latest CS2 patch date from the Steam news API.""" stop_event = threading.Event() main_window._fetch_patch_stop = stop_event - def _run(): - max_retries = 3 - retry_delay = 5 - cache_file = Path(ConfigManager.CONFIG_DIRECTORY) / "cs2_patch_cache.txt" - - def _update_ui(text, color): - def _apply(): - try: - if main_window.root.winfo_exists() and hasattr(main_window, "cs2_patch_label"): - main_window.cs2_patch_label.configure(text=text, text_color=color) - except Exception: - logger.exception("cs2_patch_label update failed") - main_window.ui_queue_put(_apply) + def _update_ui(text: str, color: str) -> None: + def _apply(): + try: + if main_window.root.winfo_exists() and hasattr(main_window, "cs2_patch_label"): + main_window.cs2_patch_label.configure(text=text, text_color=color) + except Exception: + logger.exception("cs2_patch_label update failed") + main_window.ui_queue_put(_apply) - cached = cache_file.read_text().strip() if cache_file.exists() else None - if cached: - _update_ui(cached, "#22c55e") + cache_file = Path(ConfigManager.CONFIG_DIRECTORY) / "cs2_patch_cache.txt" - url = ("https://api.steampowered.com/ISteamNews/GetNewsForApp/v2/" - "?appid=730&count=1&maxlength=1&format=json") + def _run() -> None: + # Show cached value immediately while the network request is in flight. + if cache_file.exists(): + try: + _update_ui(cache_file.read_text().strip(), _COLOR_OK) + except OSError: + pass - for attempt in range(max_retries): + url = ( + "https://api.steampowered.com/ISteamNews/GetNewsForApp/v2/" + "?appid=730&count=1&maxlength=1&format=json" + ) + for attempt in range(3): if stop_event.is_set(): return try: - resp = requests.get(url, headers={"User-Agent": "VioletWing-App"}, timeout=10) + resp = requests.get( + url, headers={"User-Agent": "VioletWing-App"}, timeout=10 + ) resp.raise_for_status() - data = orjson.loads(resp.content) + data = orjson.loads(resp.content) items = data.get("appnews", {}).get("newsitems", []) if not items: raise ValueError("No news items in Steam API response") - patch_dt = datetime.fromtimestamp(items[0]["date"]) - date_str = patch_dt.strftime("%m/%d/%Y") - cache_file.write_text(date_str) - _update_ui(date_str, _COLOR_FRESH) - with _staleness_lock: - main_window._cs2_patch_dt = patch_dt - _check_offsets_staleness(main_window) + date_str = datetime.fromtimestamp(items[0]["date"]).strftime("%m/%d/%Y") + try: + cache_file.write_text(date_str) + except OSError: + pass + _update_ui(date_str, _COLOR_OK) return except Exception as exc: - logger.error("Failed to fetch CS2 patch date: %s", exc) - if attempt < max_retries - 1: - for _ in range(retry_delay * 10): + logger.error("Failed to fetch CS2 patch date (attempt %d): %s", attempt + 1, exc) + if attempt < 2: + import time + for _ in range(50): if stop_event.is_set(): return time.sleep(0.1) - continue - _update_ui("Error", "#ef4444") + + _update_ui("Error", _COLOR_ERR) threading.Thread(target=_run, daemon=True).start() \ No newline at end of file diff --git a/gui/logs_tab.py b/gui/logs_tab.py index 223597b..faba1d6 100644 --- a/gui/logs_tab.py +++ b/gui/logs_tab.py @@ -158,7 +158,7 @@ def _create_log_body(main_window, parent): "search_hl", background="#f59e0b", foreground="#000000", ) - # Per-level foreground tags — applied to the [LEVEL] token only + # Per-level foreground tags - applied to the [LEVEL] token only tb = main_window.log_text._textbox tb.tag_config("log_debug", foreground="#6b7280") tb.tag_config("log_info", foreground="#10b981") diff --git a/gui/main_window.py b/gui/main_window.py index e9de6c8..90e73b1 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -9,7 +9,7 @@ import customtkinter as ctk from PIL import Image -from tkinter import messagebox, TclError +from tkinter import messagebox from watchdog.observers import Observer from classes.updater import Updater @@ -525,17 +525,11 @@ def update_client_status(self, status: str, color: str) -> None: if hasattr(self, "bot_status_label"): self.bot_status_label.configure(text=status, text_color=color) - def start_client(self): self.client_manager.start_client() + def start_client(self): + self.client_manager.start_client() def stop_client(self) -> None: self.client_manager.stop_client() - notice = getattr(self, "offset_source_notice", None) - if notice is not None: - try: - notice.pack_forget() - notice.configure(text="") - except TclError: - pass def update_weapon_settings_display(self) -> None: weapon_type = self.ui_bridge.get_value("active_weapon_type") @@ -613,9 +607,6 @@ def _save_general(self, config: dict) -> None: val = self.ui_bridge.get_value(key) if val is not None: s[key] = val - if self.ui_bridge.registered("OffsetSource"): - display = self.ui_bridge.get_value("OffsetSource") - s["OffsetSource"] = getattr(self, "offset_source_mapping", {}).get(display, "a2x") def _save_trigger(self, config: dict) -> None: s = config["Trigger"] @@ -1022,7 +1013,7 @@ def _apply_level_tags(self, entries: list[str]) -> None: if m: tag = self._LEVEL_TAG.get(m.group(1)) if tag: - # m.start()/end() are byte offsets into first_line — use as col indices + # m.start()/end() are byte offsets into first_line - use as col indices start = f"{line_num}.{m.start()}" end = f"{line_num}.{m.end()}" widget.tag_add(tag, start, end) diff --git a/gui/overlay_settings_tab.py b/gui/overlay_settings_tab.py index da20c85..ae64690 100644 --- a/gui/overlay_settings_tab.py +++ b/gui/overlay_settings_tab.py @@ -223,7 +223,7 @@ def _make_color_picker(parent, key, main_window): row = ctk.CTkFrame(parent, fg_color="transparent") row.pack() - # Swatch — live colored preview square + # Swatch - live colored preview square swatch = ctk.CTkFrame( row, width=28, height=28, corner_radius=6, fg_color=current_hex, border_width=1, border_color=COLOR_WIDGET_BORDER, @@ -249,7 +249,7 @@ def _make_color_picker(parent, key, main_window): ) combo.pack(side="left", padx=(0, 8)) - # Hex entry — free-form input, validated on commit + # Hex entry - free-form input, validated on commit entry = ctk.CTkEntry( row, width=100, height=45, justify="center", corner_radius=ENTRY_STYLE["corner_radius"],