From 46da0646999a797b68080503be92bf667fb089a5 Mon Sep 17 00:00:00 2001 From: MjKey Date: Tue, 16 Dec 2025 00:43:05 +0300 Subject: [PATCH] add radar hack --- Features/esp.py | 27 ++- Features/radar.py | 185 +++++++++++++++++ GFusion.py | 77 +++++++ Process/config.py | 506 ++++++++++++++++++++++++---------------------- 4 files changed, 549 insertions(+), 246 deletions(-) create mode 100644 Features/radar.py diff --git a/Features/esp.py b/Features/esp.py index 74ae80e..bef6443 100644 --- a/Features/esp.py +++ b/Features/esp.py @@ -34,6 +34,7 @@ from Process.offsets import Offsets from Process.memory_interface import MemoryInterface from Features import worldesp +from Features import radar as radar_module # Add current directory to path for vischeck.pyd current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -1658,6 +1659,10 @@ def point_in_box(px, py, pos, w, h): map_status_dragging = False map_status_drag_offset = [0, 0] +# Radar draggable globals +radar_dragging = False +radar_drag_offset = [0, 0] + def calculate_speed(vel): return math.sqrt(vel['x']**2 + vel['y']**2 + vel['z']**2) @@ -2382,7 +2387,17 @@ def handle_local_info_drag(): local_info_dragging, local_info_box_pos, local_info_drag_offset, box_width, 24 ) - + def handle_radar_drag(): + global radar_dragging, radar_drag_offset + if not getattr(Config, "radar_enabled", False): + return + radar_size = getattr(Config, "radar_size", 200) + radar_pos = [getattr(Config, "radar_x", 20), getattr(Config, "radar_y", 20)] + radar_dragging, radar_drag_offset = handle_dragging( + radar_dragging, radar_pos, radar_drag_offset, radar_size, radar_size + ) + Config.radar_x = radar_pos[0] + Config.radar_y = radar_pos[1] # Error tracking for stability consecutive_errors = 0 @@ -2773,6 +2788,16 @@ def handle_local_info_drag(): overlay.end_scene() continue + # --- Radar --- + try: + if getattr(cfg, "radar_enabled", False) and local_pos: + handle_radar_drag() + local_yaw = read_float(handle, base + Offsets.dwViewAngles + 4) + radar_module.draw_radar(overlay, local_pos, local_team, local_yaw, entities) + radar_module.draw_radar_label(overlay) + except Exception as e: + print(f"[Radar Error] {e}") + # ESP status tracking current_time = time.time() if current_time - last_esp_status_time > 10: # Every 10 seconds diff --git a/Features/radar.py b/Features/radar.py new file mode 100644 index 0000000..a11661e --- /dev/null +++ b/Features/radar.py @@ -0,0 +1,185 @@ +""" +2D Radar for CS2 - Integrated with ESP overlay +Draws on the same overlay as ESP for correct screen positioning +""" +import math +from Process.config import Config +from Process.offsets import Offsets + +# Radar state +_radar_cache = { + 'players': [], + 'local_pos': None, + 'local_team': None, + 'local_yaw': None, + 'last_update': 0 +} + +def world_to_radar(world_pos, local_pos, local_yaw, radar_size, radar_range): + """Convert world coordinates to radar coordinates""" + dx = world_pos[0] - local_pos[0] + dy = world_pos[1] - local_pos[1] + + # Rotate based on local player's yaw (add 90° to align forward with up) + yaw_rad = math.radians(-local_yaw + 90) + cos_yaw = math.cos(yaw_rad) + sin_yaw = math.sin(yaw_rad) + + # Rotate coordinates so forward = up on radar + rotated_x = dx * cos_yaw - dy * sin_yaw + rotated_y = dx * sin_yaw + dy * cos_yaw + + # Scale to radar (forward = up, so negate Y) + scale = radar_size / (2 * radar_range) + radar_x = rotated_x * scale + radar_size // 2 + radar_y = -rotated_y * scale + radar_size // 2 + + return int(radar_x), int(radar_y) + +def update_radar_cache(entities, local_pos, local_team, local_yaw): + """Update radar player cache from ESP entities""" + global _radar_cache + + players = [] + for ent in entities: + if ent.hp <= 0: + continue + players.append({ + 'pos': (ent.pos.x, ent.pos.y, ent.pos.z), + 'team': ent.team, + 'is_enemy': ent.team != local_team + }) + + _radar_cache['players'] = players + _radar_cache['local_pos'] = (local_pos.x, local_pos.y, local_pos.z) if local_pos else None + _radar_cache['local_team'] = local_team + _radar_cache['local_yaw'] = local_yaw + +def draw_radar(overlay, local_pos, local_team, local_yaw, entities=None): + """Draw 2D radar on ESP overlay""" + if not getattr(Config, "radar_enabled", False): + return + + # Update cache if entities provided + if entities is not None and local_pos is not None: + try: + update_radar_cache(entities, local_pos, local_team, local_yaw) + except Exception as e: + print(f"[Radar] Cache update error: {e}") + return + + cache = _radar_cache + if not cache['local_pos']: + return + + # Radar settings + radar_size = getattr(Config, "radar_size", 200) + radar_range = getattr(Config, "radar_range", 1500) + radar_x = getattr(Config, "radar_x", 20) + radar_y = getattr(Config, "radar_y", 20) + show_team = getattr(Config, "radar_show_team", True) + show_background = getattr(Config, "radar_show_background", True) + radar_shape = getattr(Config, "radar_shape", "square") + is_circle = radar_shape == "circle" + + # Colors (RGB only, no alpha for GDI) + bg_color = getattr(Config, "radar_bg_color", (20, 20, 20))[:3] + border_color = getattr(Config, "radar_border_color", (100, 100, 100))[:3] + enemy_color = getattr(Config, "radar_color_enemy", (255, 50, 50))[:3] + team_color = getattr(Config, "radar_color_team", (50, 150, 255))[:3] + local_color = (0, 255, 0) # Green for local player + + center_x = radar_x + radar_size // 2 + center_y = radar_y + radar_size // 2 + + try: + # Draw radar background based on shape (only if enabled) + if show_background: + if is_circle: + overlay.draw_circle(center_x, center_y, radar_size // 2, border_color) + for r in range(radar_size // 2 - 1, 0, -2): + overlay.draw_circle(center_x, center_y, r, bg_color) + else: + overlay.draw_filled_rect(radar_x, radar_y, radar_size, radar_size, bg_color) + overlay.draw_box(radar_x, radar_y, radar_size, radar_size, border_color) + + # Draw crosshairs + line_color = (60, 60, 60) + if is_circle: + half = radar_size // 2 - 5 + overlay.draw_line(center_x - half, center_y, center_x + half, center_y, line_color) + overlay.draw_line(center_x, center_y - half, center_x, center_y + half, line_color) + else: + overlay.draw_line(radar_x, center_y, radar_x + radar_size, center_y, line_color) + overlay.draw_line(center_x, radar_y, center_x, radar_y + radar_size, line_color) + + # Draw local player dot (center) + overlay.draw_filled_rect(center_x - 3, center_y - 3, 6, 6, local_color) + + # Draw direction indicator (small triangle pointing up) + overlay.draw_line(center_x, center_y - 8, center_x - 4, center_y - 2, local_color) + overlay.draw_line(center_x, center_y - 8, center_x + 4, center_y - 2, local_color) + except Exception as e: + print(f"[Radar] Draw background error: {e}") + return + + # Draw players + local_pos_tuple = cache['local_pos'] + local_yaw_val = cache['local_yaw'] or 0 + radius = radar_size // 2 + + for player in cache['players']: + # Skip teammates if not showing team + if not player['is_enemy'] and not show_team: + continue + + # Convert world position to radar position + px, py = world_to_radar( + player['pos'], local_pos_tuple, local_yaw_val, + radar_size, radar_range + ) + + # Check if point is within radar bounds + if is_circle: + # For circle, check distance from center + dist = math.sqrt((px - radius)**2 + (py - radius)**2) + if dist > radius - 2: + continue + else: + # For square, check bounds + if px < 0 or px > radar_size or py < 0 or py > radar_size: + continue + + # Choose color based on team + color = enemy_color if player['is_enemy'] else team_color + + # Draw player dot + dot_x = radar_x + px + dot_y = radar_y + py + dot_size = 5 + overlay.draw_filled_rect(dot_x - dot_size//2, dot_y - dot_size//2, dot_size, dot_size, color) + +def draw_radar_label(overlay): + """Draw radar label""" + if not getattr(Config, "radar_enabled", False): + return + + radar_x = getattr(Config, "radar_x", 20) + radar_y = getattr(Config, "radar_y", 20) + radar_size = getattr(Config, "radar_size", 200) + + # Draw "RADAR" label above + # overlay.draw_text("RADAR", radar_x + radar_size // 2, radar_y - 16, (200, 200, 200), 12, centered=True) + + +# Legacy class for backwards compatibility (if GFusion.py imports it) +class CS2RadarManager: + """Legacy wrapper - radar now integrated into ESP""" + def __init__(self, shared_config=None): + self.shared_config = shared_config + print("[Radar] Radar is now integrated into ESP overlay") + + def run(self): + """No-op - radar runs through ESP now""" + print("[Radar] Radar draws through ESP overlay - no separate thread needed") + pass diff --git a/GFusion.py b/GFusion.py index d2f59d3..f38cd1e 100644 --- a/GFusion.py +++ b/GFusion.py @@ -23,6 +23,7 @@ from Features.bhop import BHopProcess from Features.fov import FOVChanger from Features.glow import CS2GlowManager +from Features.radar import CS2RadarManager from Features.triggerbot import TriggerBot from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure @@ -900,6 +901,15 @@ def run_glow(): except Exception as e: logger.exception(f"Glow crashed: {e}", category="Glow") +def run_radar(): + global radar_manager + logger.info("Starting radar", category="Radar") + try: + radar_manager = CS2RadarManager(cfg) + radar_manager.run() + except Exception as e: + logger.exception(f"Radar crashed: {e}", category="Radar") + def run_triggerbot(): global triggerbot_instance logger.info("Starting triggerbot", category="Triggerbot") @@ -988,6 +998,18 @@ def start_glow_thread(): except Exception as e: logger.error(f"Failed to start glow thread: {e}", category="Threading", exc_info=True) +def start_radar_thread(): + global radar_thread + try: + if radar_thread is None or not radar_thread.is_alive(): + Config.radar_stop = False + Config.radar_enabled = True + radar_thread = threading.Thread(target=run_radar, daemon=True, name="RadarThread") + radar_thread.start() + logger.info("Radar thread started", category="Threading") + except Exception as e: + logger.error(f"Failed to start radar thread: {e}", category="Threading", exc_info=True) + def start_toggle_listener(main_window): def _toggle_menu(): """Runs on the Qt (GUI) thread via signal.""" @@ -1150,6 +1172,17 @@ def stop_glow_thread(): except Exception as e: logger.error(f"Failed to stop glow: {e}", category="Threading", exc_info=True) +def stop_radar_thread(): + global radar_thread, radar_manager + try: + Config.radar_enabled = False + Config.radar_stop = True + radar_thread = None + radar_manager = None + logger.info("Radar stop signal sent", category="Threading") + except Exception as e: + logger.error(f"Failed to stop radar: {e}", category="Threading", exc_info=True) + def stop_triggerbot_thread(): try: cfg.triggerbot_enabled = False @@ -3128,6 +3161,46 @@ def set_panic(): colors_layout.addLayout(color_grid) layout.addWidget(colors_group) + # === 2D Radar === + radar_group = self.create_group_box("2D Radar") + radar_layout = QVBoxLayout(radar_group) + radar_layout.setSpacing(4) + + r_row1 = QHBoxLayout() + self.add_checkbox(r_row1, "Enable Radar", "radar_enabled") + self.add_checkbox(r_row1, "Show Team", "radar_show_team") + self.add_checkbox(r_row1, "Show Background", "radar_show_background") + radar_layout.addLayout(r_row1) + + self.add_slider(radar_layout, "Size", "radar_size", 100, 400) + self.add_slider(radar_layout, "Range", "radar_range", 500, 5000) + + shape_row = QHBoxLayout() + shape_lbl = QLabel("Shape:") + shape_lbl.setStyleSheet("color: #f5f5f7;") + shape_row.addWidget(shape_lbl) + shape_combo = QComboBox() + shape_combo.addItems(["square", "circle"]) + shape_combo.setCurrentText(getattr(Config, "radar_shape", "square")) + shape_combo.setStyleSheet("QComboBox { background: #2d2d30; color: #f5f5f7; border: 1px solid #3e3e42; padding: 4px; }") + shape_combo.currentTextChanged.connect(lambda t: setattr(Config, "radar_shape", t)) + shape_row.addWidget(shape_combo) + shape_row.addStretch() + radar_layout.addLayout(shape_row) + + drag_hint = QLabel("💡 Drag radar in-game to reposition") + drag_hint.setStyleSheet("color: #888; font-size: 10px;") + radar_layout.addWidget(drag_hint) + + r_color_grid = QGridLayout() + self.add_color_picker_to_grid(r_color_grid, 0, 0, "Background", "radar_bg_color", (20, 20, 20)) + self.add_color_picker_to_grid(r_color_grid, 0, 1, "Border", "radar_border_color", (100, 100, 100)) + self.add_color_picker_to_grid(r_color_grid, 1, 0, "Enemy", "radar_color_enemy", (255, 50, 50)) + self.add_color_picker_to_grid(r_color_grid, 1, 1, "Team", "radar_color_team", (50, 150, 255)) + radar_layout.addLayout(r_color_grid) + radar_layout.addStretch() + layout.addWidget(radar_group) + layout.addStretch() scroll.setWidget(content) main_layout = QVBoxLayout(self) @@ -4952,6 +5025,10 @@ def refresh_ui(self): glow_manager = None +radar_thread = None + +radar_manager = None + triggerbot_thread = None triggerbot_instance = None diff --git a/Process/config.py b/Process/config.py index 28e4758..5f4a8a9 100644 --- a/Process/config.py +++ b/Process/config.py @@ -203,6 +203,22 @@ class Config: glow_color_enemy = (1, 0, 0, 1) glow_color_team = (0, 0, 1, 1) + # =========================== + # 2D Radar (integrated into ESP) + # =========================== + radar_enabled = True + radar_show_team = True + radar_show_background = True + radar_size = 200 + radar_range = 1500 + radar_x = 20 + radar_y = 20 + radar_bg_color = (20, 20, 20) + radar_border_color = (100, 100, 100) + radar_color_enemy = (255, 50, 50) + radar_color_team = (50, 150, 255) + radar_shape = "square" # "square" or "circle" + # =========================== # TriggerBot # =========================== @@ -268,249 +284,249 @@ class Config: reaction_delay_min = 0.01 reaction_delay_max = 0.08 overshoot_enabled = True - overshoot_chance = 0.15 - overshoot_amount = 1.2 - - # =========================== - # Aimbot Learning & Prediction - # =========================== - enable_learning = True - learn_dir = "aimbot_data" - enable_velocity_prediction = False - velocity_prediction_factor = 0.1 - enable_mouse_recording = True - - # =========================== - # Aimbot Smooth / Sensitivity - # =========================== - smooth_base = 0.24 - smooth_var = 0.00 - sensitivity = 0.022 - invert_y = -1 - max_mouse_move = 5 - - # =========================== - # Recoil Control System (RCS) - # =========================== - rcs_enabled = True - rcs_scale = 2.0 - rcs_smooth_base = 1.00 - rcs_smooth_var = 0.01 - - # =========================== - # Kernel Mode Driver (NeacController) - # =========================== - kernel_mode_enabled = False - kernel_driver_auto_start = False - kernel_fallback_to_usermode = False - - # =========================== - # Utility - # =========================== - aim_stop = False - - # ------------------------------ - # Serialization helpers - # ------------------------------ - @classmethod - def _json_safe(cls, value): - """Convert tuples -> lists (JSON), keep primitives, lists, dicts as-is.""" - if isinstance(value, tuple): - return list(value) - return value - - @classmethod - def _normalize_loaded_value(cls, key, loaded_value): - """Convert lists back to tuples when the default is a tuple; clamp color types.""" - try: - default = getattr(cls, key) - except AttributeError: - return loaded_value - - # tuple restoration - if isinstance(default, tuple) and isinstance(loaded_value, list): - loaded_value = tuple(loaded_value) - - # basic color sanity (0..255 for rgb[a] ints; allow floats 0..1 in glow colors) - if "color" in key: - if isinstance(loaded_value, tuple): - if all(isinstance(x, (int, float)) for x in loaded_value): - # if default is float rgba (like glow), keep floats in 0..1 - if all(isinstance(x, float) for x in default): - loaded_value = tuple(max(0.0, min(1.0, float(x))) for x in loaded_value) - else: - loaded_value = tuple(max(0, min(255, int(x))) for x in loaded_value) - return loaded_value - - @classmethod - def to_dict(cls): - """Class -> plain dict suitable for JSON (filters dunders/callables).""" - result = {"schema_version": cls.schema_version} - for key in dir(cls): - if key.startswith("_"): - continue - try: - value = getattr(cls, key) - except Exception: - continue - if callable(value): - continue - # Skip properties (rare) - if isinstance(value, property): - continue - result[key] = cls._json_safe(value) - return result - - @classmethod - def from_dict(cls, data: dict): - """Apply values conservatively; ignore unknown, normalize types.""" - for key, value in data.items(): - if key == "schema_version": - continue - if hasattr(cls, key): - try: - norm = cls._normalize_loaded_value(key, value) - setattr(cls, key, norm) - except Exception as e: - print(f"[Config] Warn: Could not set {key} = {value}: {e}") - else: - # stay quiet or log once if you prefer - # print(f"[Config] Info: Unknown key '{key}' ignored") - pass - - # Backfill new keys if missing (keeps older configs working) - if not hasattr(cls, "autosave_enabled"): - cls.autosave_enabled = False - if not hasattr(cls, "autosave_minutes"): - cls.autosave_minutes = 5 - if not hasattr(cls, "configs_dir"): - cls.configs_dir = "config" - if not hasattr(cls, "current_config_name"): - cls.current_config_name = "default" - - # ------------------------------ - # Persistence API - # ------------------------------ - @classmethod - def _config_path(cls, filename: str) -> str: - os.makedirs(cls.configs_dir, exist_ok=True) - return os.path.join(cls.configs_dir, f"{filename}.json") - - @classmethod - def save_to_file(cls, filename: str): - """ - Atomic save: - - writes to .json.tmp - - renames existing file to .bak (one backup) - - renames tmp -> final - Filters runtime-only keys if you add them below. - """ - path = cls._config_path(filename) - tmp_path = path + ".tmp" - bak_path = path + ".bak" - - try: - data = cls.to_dict() - - # ---- strip runtime-only keys here if any appear in class namespace - runtime_only = { - # visibility runtime state (kept in-memory only) - "visibility_map_path", - "visibility_map_loaded", - "visibility_map_reload_needed", - # ephemeral flags - "panic_mode_active", - } - for k in runtime_only: - data.pop(k, None) - - # DO NOT persist map file name if you prefer auto-detect per session: - # (If you actually want to persist it, comment out the next line.) - data.pop("visibility_map_file", None) - - # write tmp - with open(tmp_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - - # rotate backup (best-effort) - if os.path.exists(path): - try: - # keep 1 .bak; overwrite - shutil.copyfile(path, bak_path) - except Exception as e: - print(f"[Config] Backup warn: {e}") - - # commit - os.replace(tmp_path, path) - cls.current_config_name = filename - print(f"[Config] Saved {len(data)} settings to {os.path.basename(path)}") - - except Exception as e: - try: - if os.path.exists(tmp_path): - os.remove(tmp_path) - except Exception: - pass - print(f"[Config] Error saving '{filename}': {e}") - raise - - @classmethod - def load_from_file(cls, filename: str): - """ - Load config safely: - - reads JSON - - applies with type normalization - - backfills defaults for newly added fields - """ - path = cls._config_path(filename) - if not os.path.exists(path): - print(f"[Config] {os.path.basename(path)} not found, using defaults") - return - - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - - # migrate if older schemas appear (hook for future upgrades) - file_schema = data.get("schema_version", 0) - if file_schema < cls.schema_version: - data = cls._migrate_schema(data, file_schema) - - cls.from_dict(data) - - # Ensure visibility defaults exist even if older file lacked them - if not hasattr(cls, 'visibility_esp_enabled'): - cls.visibility_esp_enabled = False - if not hasattr(cls, 'visibility_text_enabled'): - cls.visibility_text_enabled = True - if not hasattr(cls, 'color_visible_text'): - cls.color_visible_text = (0, 255, 0) - if not hasattr(cls, 'color_not_visible_text'): - cls.color_not_visible_text = (255, 0, 0) - - cls.current_config_name = filename - print(f"[Config] Loaded config from {os.path.basename(path)}") - - except json.JSONDecodeError as e: - print(f"[Config] Error: Invalid JSON in {os.path.basename(path)}: {e}") - except Exception as e: - print(f"[Config] Error loading '{filename}': {e}") - - @classmethod - def read_config_dict(cls, filename: str): - """Helper used by Export in the Config tab.""" - path = cls._config_path(filename) - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - - # ------------------------------ - # Schema migration (stub) - # ------------------------------ - @classmethod - def _migrate_schema(cls, data: dict, from_version: int) -> dict: - """If we bump schema_version later, transform older data -> new format.""" - migrated = dict(data) - # Example: if from_version == 0: (perform mappings) - # migrated["schema_version"] = cls.schema_version - migrated["schema_version"] = cls.schema_version + overshoot_chance = 0.15 + overshoot_amount = 1.2 + + # =========================== + # Aimbot Learning & Prediction + # =========================== + enable_learning = True + learn_dir = "aimbot_data" + enable_velocity_prediction = False + velocity_prediction_factor = 0.1 + enable_mouse_recording = True + + # =========================== + # Aimbot Smooth / Sensitivity + # =========================== + smooth_base = 0.24 + smooth_var = 0.00 + sensitivity = 0.022 + invert_y = -1 + max_mouse_move = 5 + + # =========================== + # Recoil Control System (RCS) + # =========================== + rcs_enabled = True + rcs_scale = 2.0 + rcs_smooth_base = 1.00 + rcs_smooth_var = 0.01 + + # =========================== + # Kernel Mode Driver (NeacController) + # =========================== + kernel_mode_enabled = False + kernel_driver_auto_start = False + kernel_fallback_to_usermode = False + + # =========================== + # Utility + # =========================== + aim_stop = False + + # ------------------------------ + # Serialization helpers + # ------------------------------ + @classmethod + def _json_safe(cls, value): + """Convert tuples -> lists (JSON), keep primitives, lists, dicts as-is.""" + if isinstance(value, tuple): + return list(value) + return value + + @classmethod + def _normalize_loaded_value(cls, key, loaded_value): + """Convert lists back to tuples when the default is a tuple; clamp color types.""" + try: + default = getattr(cls, key) + except AttributeError: + return loaded_value + + # tuple restoration + if isinstance(default, tuple) and isinstance(loaded_value, list): + loaded_value = tuple(loaded_value) + + # basic color sanity (0..255 for rgb[a] ints; allow floats 0..1 in glow colors) + if "color" in key: + if isinstance(loaded_value, tuple): + if all(isinstance(x, (int, float)) for x in loaded_value): + # if default is float rgba (like glow), keep floats in 0..1 + if all(isinstance(x, float) for x in default): + loaded_value = tuple(max(0.0, min(1.0, float(x))) for x in loaded_value) + else: + loaded_value = tuple(max(0, min(255, int(x))) for x in loaded_value) + return loaded_value + + @classmethod + def to_dict(cls): + """Class -> plain dict suitable for JSON (filters dunders/callables).""" + result = {"schema_version": cls.schema_version} + for key in dir(cls): + if key.startswith("_"): + continue + try: + value = getattr(cls, key) + except Exception: + continue + if callable(value): + continue + # Skip properties (rare) + if isinstance(value, property): + continue + result[key] = cls._json_safe(value) + return result + + @classmethod + def from_dict(cls, data: dict): + """Apply values conservatively; ignore unknown, normalize types.""" + for key, value in data.items(): + if key == "schema_version": + continue + if hasattr(cls, key): + try: + norm = cls._normalize_loaded_value(key, value) + setattr(cls, key, norm) + except Exception as e: + print(f"[Config] Warn: Could not set {key} = {value}: {e}") + else: + # stay quiet or log once if you prefer + # print(f"[Config] Info: Unknown key '{key}' ignored") + pass + + # Backfill new keys if missing (keeps older configs working) + if not hasattr(cls, "autosave_enabled"): + cls.autosave_enabled = False + if not hasattr(cls, "autosave_minutes"): + cls.autosave_minutes = 5 + if not hasattr(cls, "configs_dir"): + cls.configs_dir = "config" + if not hasattr(cls, "current_config_name"): + cls.current_config_name = "default" + + # ------------------------------ + # Persistence API + # ------------------------------ + @classmethod + def _config_path(cls, filename: str) -> str: + os.makedirs(cls.configs_dir, exist_ok=True) + return os.path.join(cls.configs_dir, f"{filename}.json") + + @classmethod + def save_to_file(cls, filename: str): + """ + Atomic save: + - writes to .json.tmp + - renames existing file to .bak (one backup) + - renames tmp -> final + Filters runtime-only keys if you add them below. + """ + path = cls._config_path(filename) + tmp_path = path + ".tmp" + bak_path = path + ".bak" + + try: + data = cls.to_dict() + + # ---- strip runtime-only keys here if any appear in class namespace + runtime_only = { + # visibility runtime state (kept in-memory only) + "visibility_map_path", + "visibility_map_loaded", + "visibility_map_reload_needed", + # ephemeral flags + "panic_mode_active", + } + for k in runtime_only: + data.pop(k, None) + + # DO NOT persist map file name if you prefer auto-detect per session: + # (If you actually want to persist it, comment out the next line.) + data.pop("visibility_map_file", None) + + # write tmp + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + # rotate backup (best-effort) + if os.path.exists(path): + try: + # keep 1 .bak; overwrite + shutil.copyfile(path, bak_path) + except Exception as e: + print(f"[Config] Backup warn: {e}") + + # commit + os.replace(tmp_path, path) + cls.current_config_name = filename + print(f"[Config] Saved {len(data)} settings to {os.path.basename(path)}") + + except Exception as e: + try: + if os.path.exists(tmp_path): + os.remove(tmp_path) + except Exception: + pass + print(f"[Config] Error saving '{filename}': {e}") + raise + + @classmethod + def load_from_file(cls, filename: str): + """ + Load config safely: + - reads JSON + - applies with type normalization + - backfills defaults for newly added fields + """ + path = cls._config_path(filename) + if not os.path.exists(path): + print(f"[Config] {os.path.basename(path)} not found, using defaults") + return + + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + + # migrate if older schemas appear (hook for future upgrades) + file_schema = data.get("schema_version", 0) + if file_schema < cls.schema_version: + data = cls._migrate_schema(data, file_schema) + + cls.from_dict(data) + + # Ensure visibility defaults exist even if older file lacked them + if not hasattr(cls, 'visibility_esp_enabled'): + cls.visibility_esp_enabled = False + if not hasattr(cls, 'visibility_text_enabled'): + cls.visibility_text_enabled = True + if not hasattr(cls, 'color_visible_text'): + cls.color_visible_text = (0, 255, 0) + if not hasattr(cls, 'color_not_visible_text'): + cls.color_not_visible_text = (255, 0, 0) + + cls.current_config_name = filename + print(f"[Config] Loaded config from {os.path.basename(path)}") + + except json.JSONDecodeError as e: + print(f"[Config] Error: Invalid JSON in {os.path.basename(path)}: {e}") + except Exception as e: + print(f"[Config] Error loading '{filename}': {e}") + + @classmethod + def read_config_dict(cls, filename: str): + """Helper used by Export in the Config tab.""" + path = cls._config_path(filename) + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + # ------------------------------ + # Schema migration (stub) + # ------------------------------ + @classmethod + def _migrate_schema(cls, data: dict, from_version: int) -> dict: + """If we bump schema_version later, transform older data -> new format.""" + migrated = dict(data) + # Example: if from_version == 0: (perform mappings) + # migrated["schema_version"] = cls.schema_version + migrated["schema_version"] = cls.schema_version return migrated \ No newline at end of file