""" KSP SFS (Save File) Parser - Line-by-line stack-based, O(n). Format: KEY <- block header { <- open brace (same indent as header) ... <- contents (deeper indent) } <- close brace key = value <- assignment """ import json import os import sys from typing import Any from collections import defaultdict from datetime import datetime, timedelta, timezone # --------------------------------------------------------------------------- # Time conversion constants # --------------------------------------------------------------------------- # Game epoch: UT=0 corresponds to this real-world datetime. # All UT values in the save file are seconds elapsed since this moment. GAME_EPOCH = datetime(2051, 1, 1, 0, 0, 0, tzinfo=timezone.utc) # RSS (Real Solar System) uses 86400 seconds per Earth day for display. SECONDS_PER_DAY = 86400 SECONDS_PER_YEAR = 365.25 * SECONDS_PER_DAY def ut_to_datetime(ut_seconds: float | int) -> datetime: """Convert a KSP UT seconds value to an absolute datetime.""" if ut_seconds is None: return None return GAME_EPOCH + timedelta(seconds=float(ut_seconds)) def ut_to_datestr(ut_seconds: float | int) -> str: """Format UT seconds as human-readable date string.""" if ut_seconds is None: return "N/A" dt = ut_to_datetime(ut_seconds) return dt.strftime("%Y-%m-%d %H:%M UTC") def seconds_to_duration(seconds: float | int) -> str: """Format seconds into a human-readable duration (years, days, hours, minutes).""" if seconds is None: return "N/A" total = float(seconds) if total < 60: return f"{total:.0f}s" minutes = total / 60 if total < 3600: return f"{minutes:.0f}m" hours = total / 3600 if total < SECONDS_PER_DAY: return f"{hours:.1f}h" days = int(total // SECONDS_PER_DAY) remainder = total % SECONDS_PER_DAY hours_part = remainder / 3600 years = days // 365 remaining_days = days % 365 if years > 0: if remaining_days > 0: return f"{years}y {remaining_days}d {hours_part:.0f}h" else: return f"{years}y {hours_part:.0f}h" else: return f"{days}d {hours_part:.0f}h" def parse_sfs_fast(filepath: str) -> dict: root = {} # Stack frames: (name, container_dict) stack: list[tuple[str, dict]] = [] pending_key: str | None = None # Key seen on previous line (awaiting '{') pending_depth: int = -1 with open(filepath, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() for line in lines: stripped = line.rstrip("\n\r") if not stripped: continue # Count leading tabs = depth depth = 0 for ch in stripped: if ch == "\t": depth += 1 else: break content = stripped[depth:] if not content: continue if content == "{": # Open brace: push the pending key as a new container if pending_key is not None and pending_depth == depth: new_container = {} parent = stack[-1][1] if stack else root _insert_into(parent, pending_key, new_container) stack.append((pending_key, new_container)) pending_key = None continue if content == "}": # Close brace: pop one level while stack and stack[-1][1] is not None: # Pop all frames at or deeper than this depth if len(stack) <= depth: break stack.pop() pending_key = None continue # Check if this is an assignment: key = value if " = " in content: parts = content.split(" = ", 1) key = parts[0].strip() val_str = parts[1].strip() val = _parse_value(val_str) parent = stack[-1][1] if stack else root _insert_into(parent, key, val) pending_key = None continue # Otherwise: it's a potential block header # Store it; next line will tell us if it's followed by '{' pending_key = content.strip() pending_depth = depth return root def _parse_value(val_str: str) -> Any: val_str = val_str.rstrip(",") if val_str == "True": return True if val_str == "False": return False if val_str == "None": return None if "," in val_str: parts = [p.strip() for p in val_str.split(",")] result = [] for p in parts: try: result.append(int(p)) except ValueError: try: result.append(float(p)) except ValueError: result.append(p) return result try: return int(val_str) except ValueError: pass try: return float(val_str) except ValueError: pass return val_str def _insert_into(parent: dict, key: str, val: Any) -> None: """Insert key=val into parent, converting to list on duplicate.""" if key in parent: existing = parent[key] if isinstance(existing, list): existing.append(val) else: parent[key] = [existing, val] else: parent[key] = val # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _get_list(node: dict, key: str) -> list: val = node.get(key, []) if val is None: return [] if isinstance(val, list): return val return [val] # --------------------------------------------------------------------------- # Extractors # --------------------------------------------------------------------------- def extract_vessels(flightstate: dict) -> list[dict]: vessels = _get_list(flightstate, "VESSEL") results = [] for v in vessels: parts = _get_list(v, "PART") orbit = v.get("ORBIT", {}) crew = [] resources = {} for part in parts: modules = _get_list(part, "MODULE") for mod in modules: if mod.get("name") == "ModuleCommand": crew_raw = mod.get("crew", []) if isinstance(crew_raw, str): crew.append(crew_raw) elif isinstance(crew_raw, list): crew.extend([c for c in crew_raw if isinstance(c, str)]) for res in _get_list(mod, "RESOURCE"): rname = res.get("name", "?") amt = float(res.get("amount", 0)) max_amt = float(res.get("maxAmount", 0)) if rname not in resources: resources[rname] = {"amount": 0.0, "maxAmount": 0.0} resources[rname]["amount"] += amt resources[rname]["maxAmount"] += max_amt results.append({ "name": v.get("name"), "type": v.get("type"), "sit": v.get("sit"), "landed": v.get("landed"), "lat": v.get("lat"), "lon": v.get("lon"), "alt": v.get("alt"), "met": v.get("met"), "met_str": seconds_to_duration(v.get("met")), "lct": v.get("lct"), "lct_str": ut_to_datestr(v.get("lct")), # launch date "lastUT": v.get("lastUT"), "lastUT_str": ut_to_datestr(v.get("lastUT")), # last contact "orbit_body": orbit.get("IDENT"), "orbit_sma": orbit.get("SMA"), "orbit_ecc": orbit.get("ECC"), "orbit_inc": orbit.get("INC"), "parts_count": len(parts), "crew": crew, "crew_count": len(crew), "resources": resources, }) return results def extract_kerbals(roster: dict) -> list[dict]: result = [] for k in _get_list(roster, "KERBAL"): career_log = k.get("CAREER_LOG", {}) # flight count is stored as integer in "flight" key flight_count = career_log.get("flight", 0) if isinstance(flight_count, list): flight_count = len(flight_count) elif not isinstance(flight_count, int): flight_count = 0 result.append({ "name": k.get("name"), "gender": k.get("gender"), "type": k.get("type"), "trait": k.get("trait"), "state": k.get("state"), "veteran": k.get("veteran"), "hero": k.get("hero"), "badS": k.get("badS"), "brave": k.get("brave"), "dumb": k.get("dumb"), "tour": k.get("tour"), "inactive": k.get("inactive"), "extraXP": k.get("extraXP", 0), "flights": flight_count, }) return result def extract_contracts(game: dict) -> list[dict]: for sc in _get_list(game, "SCENARIO"): if sc.get("name") == "ContractSystem": result = [] contracts_block = sc.get("CONTRACTS", {}) for c in _get_list(contracts_block, "CONTRACT"): result.append({ "guid": c.get("guid"), "type": c.get("type"), "prestige": c.get("prestige"), "state": c.get("state"), "agent": c.get("agent"), "title": c.get("title"), "targetBody": c.get("targetBody"), "deadlineType": c.get("deadlineType"), "expiryType": c.get("expiryType"), }) return result return [] # --------------------------------------------------------------------------- # Kerbal Experience / Achievement Extraction # --------------------------------------------------------------------------- # Achievement tiers ordered by prestige (higher = more XP) ACHIEVEMENT_TIERS = { "PlantFlag": 5, "Land": 4, "Suborbit": 3, "Orbit": 3, "ExitVessel": 2, # EVA "Flyby": 2, "Escape": 1, "BoardVessel": 1, "Flight": 1, "Recover": 0, "Die": -1, } ACHIEVEMENT_LABELS = { "PlantFlag": "Flag", "Land": "Land", "Suborbit": "SubOrbit", "Orbit": "Orbit", "ExitVessel": "EVA", "Flyby": "Flyby", "Escape": "Escape", "BoardVessel": "Board", "Flight": "Flight", } def extract_achievements(kerbal: dict) -> dict: """ Extract unique achievements from a kerbal's CAREER_LOG. Returns a dict with: - total_flights: int - bodies_visited: set of body names - achievements: dict[body_name, set of achievement types] - highest_per_body: dict[body_name, str] - highest achievement per body - eva_bodies: set of bodies where EVA was performed - flags_planted: set of bodies where flag was planted """ career_log = kerbal.get("CAREER_LOG", {}) flight_count = career_log.get("flight", 0) if isinstance(flight_count, list): flight_count = len(flight_count) if not isinstance(flight_count, (int, float)): flight_count = 0 flight_count = int(flight_count) achievements: dict[str, set] = defaultdict(set) all_bodies: set[str] = set() for key in career_log: if not str(key).isdigit(): continue flight_data = career_log[key] if not isinstance(flight_data, list): continue for event in flight_data: if isinstance(event, list) and len(event) == 2: achievement, body = event if achievement in ACHIEVEMENT_TIERS: achievements[str(body)].add(str(achievement)) all_bodies.add(str(body)) elif isinstance(event, str): # Single string like "Recover" or "Die" pass # Determine highest achievement per body highest_per_body = {} for body, ach_set in achievements.items(): best_tier = -1 best_name = None for ach in ach_set: tier = ACHIEVEMENT_TIERS.get(ach, 0) if tier > best_tier: best_tier = tier best_name = ach highest_per_body[str(body)] = best_name # Also check FLIGHT_LOG for ongoing flight flight_log = kerbal.get("FLIGHT_LOG", {}) fl_flight_count = flight_log.get("flight", 0) if isinstance(fl_flight_count, (int, float)) and fl_flight_count > 0: for key in flight_log: if str(key).isdigit(): fl_data = flight_log[key] if isinstance(fl_data, list): for event in fl_data: if isinstance(event, list) and len(event) == 2: achievement, body = event if achievement in ACHIEVEMENT_TIERS: achievements[str(body)].add(str(achievement)) all_bodies.add(str(body)) ach = str(achievement) tier = ACHIEVEMENT_TIERS.get(ach, 0) prev = highest_per_body.get(str(body)) prev_tier = ACHIEVEMENT_TIERS.get(prev, -1) if prev else -1 if tier > prev_tier: highest_per_body[str(body)] = ach return { "total_flights": flight_count, "bodies_visited": sorted(all_bodies), "body_count": len(all_bodies), "achievements": {b: sorted(a) for b, a in achievements.items()}, "highest_per_body": highest_per_body, "flags_planted": [b for b, ach in highest_per_body.items() if ach == "PlantFlag"], "eva_bodies": [b for b, a in achievements.items() if "ExitVessel" in a], } def format_achievement_line(ach_data: dict) -> str: """Format a kerbal's achievements as a single-line summary.""" flags = ach_data.get("flags_planted", []) eva_bodies = ach_data.get("eva_bodies", []) highest = ach_data.get("highest_per_body", {}) # Build a compact summary: body → best achievement parts = [] for body in ach_data.get("bodies_visited", []): ach = highest.get(body, "?") label = ACHIEVEMENT_LABELS.get(ach, ach) parts.append(f"{body}:{label}") summary = ", ".join(parts) if parts else "(no achievements)" # Mark flags and EVAs extras = [] if flags: extras.append(f"{len(flags)} flags") if eva_bodies: extras.append(f"EVA at {len(eva_bodies)} bodies") return f"[{ach_data['total_flights']} flights, {ach_data['body_count']} bodies] {summary}" + ( " | " + ", ".join(extras) if extras else "" ) def rank_kerbals_by_experience(kerbals_achievements: list[dict]) -> list[dict]: """Sort kerbals by a composite experience score.""" scored = [] for entry in kerbals_achievements: ach = entry["achievements_data"] # Score: sum of highest tier per body score = 0 for body, best_ach in ach["highest_per_body"].items(): score += ACHIEVEMENT_TIERS.get(best_ach, 0) # Bonus for variety score += ach["body_count"] * 0.5 # Bonus for flags score += len(ach["flags_planted"]) * 2 scored.append((score, entry)) scored.sort(key=lambda x: -x[0]) return [entry for _, entry in scored] # --------------------------------------------------------------------------- # Report # --------------------------------------------------------------------------- def print_report(tree: dict) -> None: game = tree.get("GAME", tree) print("=" * 72) print(" KSP SAVE FILE ANALYSIS") print("=" * 72) print(f"\n Title: {game.get('Title')}") print(f" Version: {game.get('version')}") print(f" Mode: {game.get('Mode')}") print(f" Next Launch: #{game.get('launchID')}") fs = game.get("FLIGHTSTATE", {}) ut = fs.get("UT", 0) if ut: dt = ut_to_datetime(ut) ut_str = f"{dt.strftime('%Y-%m-%d %H:%M:%S UTC')} (Epoch: 2051-01-01, +{seconds_to_duration(ut)})" else: ut_str = "N/A" print(f"\n--- FLIGHTSTATE ---") print(f" Current Time: {ut_str}") print(f" UT Seconds: {ut:,.0f}") vessels = extract_vessels(fs) print(f" Total Vessels: {len(vessels)}") # Vessels by type and situation by_type = {} by_sit = {} for v in vessels: t = v["type"] or "?" s = v["sit"] or "?" by_type[t] = by_type.get(t, 0) + 1 by_sit[s] = by_sit.get(s, 0) + 1 print(" By Type:") for t, c in sorted(by_type.items(), key=lambda x: -x[1]): print(f" {t:20s}: {c:4d}") print(" By Situation:") for s, c in sorted(by_sit.items(), key=lambda x: -x[1]): print(f" {s:20s}: {c:4d}") # Roster roster = game.get("ROSTER", {}) kerbals = extract_kerbals(roster) print(f"\n--- ROSTER: {len(kerbals)} Kerbals ---") by_state = {} by_trait = {} for k in kerbals: by_state[k["state"] or "?"] = by_state.get(k["state"] or "?", 0) + 1 by_trait[k["trait"] or "?"] = by_trait.get(k["trait"] or "?", 0) + 1 print(" By State:") for s, c in sorted(by_state.items(), key=lambda x: -x[1]): print(f" {s:15s}: {c:4d}") print(" By Trait:") for t, c in sorted(by_trait.items(), key=lambda x: -x[1]): print(f" {t:15s}: {c:4d}") # Vessels detail print(f"\n--- VESSELS ({len(vessels)}) ---") print(f" {'Name':32s} {'Type':10s} {'Sit':7s} {'Body':8s} {'Parts':>5s} {'Crew':>4s} {'Launched':16s} {'Mission Time'}") print(f" {'-'*32} {'-'*10} {'-'*7} {'-'*8} {'-'*5} {'-'*4} {'-'*16} {'-'*15}") for v in vessels: lct = v.get('lct_str', '') or 'N/A' met = v.get('met_str', '') or 'N/A' print( f" {v['name'] or '?':32.32s} {v['type'] or '?':10.10s} {v['sit'] or '?':7.7s} " f"{v['orbit_body'] or '?':8.8s} {v['parts_count']:5d} {v['crew_count']:4d} " f"{lct:16s} {met}" ) # Contracts contracts = extract_contracts(game) print(f"\n--- CONTRACTS ({len(contracts)}) ---") if contracts: for c in contracts: print(f" [{c['state']}] {c['title']} (by {c['agent']}, target: {c['targetBody']})") print("\n" + "=" * 72) # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- if __name__ == "__main__": filepath = sys.argv[1] if len(sys.argv) > 1 else "user_input/persistent.sfs" if not os.path.exists(filepath): print(f"File not found: {filepath}") sys.exit(1) print(f"Parsing {filepath} ({os.path.getsize(filepath)/1024/1024:.1f} MB)...") tree = parse_sfs_fast(filepath) print("Parsing complete.\n") print_report(tree) flags = set(sys.argv[1:]) if "--json" in flags: outpath = filepath.replace(".sfs", "_parsed.json") print(f"\nSaving full JSON to {outpath}...") with open(outpath, "w", encoding="utf-8") as f: json.dump(tree, f, indent=2, default=str, ensure_ascii=False) print("Done.") if "--vessels" in flags: vessels = extract_vessels(tree.get("GAME", tree).get("FLIGHTSTATE", {})) print(json.dumps(vessels, indent=2, default=str, ensure_ascii=False)) if "--kerbals" in flags: kerbals = extract_kerbals(tree.get("GAME", tree).get("ROSTER", {})) print(json.dumps(kerbals, indent=2, default=str, ensure_ascii=False)) if "--contracts" in flags: print(json.dumps(extract_contracts(tree.get("GAME", tree)), indent=2, default=str, ensure_ascii=False)) if "--assignments" in flags: vessels = extract_vessels(tree.get("GAME", tree).get("FLIGHTSTATE", {})) assignments = [] for v in vessels: for c in v["crew"]: assignments.append({"kerbal": c, "vessel": v["name"]}) print(json.dumps(assignments, indent=2, default=str, ensure_ascii=False)) if "--experience" in flags or "--xp" in flags: roster = tree.get("GAME", tree).get("ROSTER", {}) kerbals = _get_list(roster, "KERBAL") results = [] for k in kerbals: ek = { "name": k.get("name"), "trait": k.get("trait"), "type": k.get("type"), "state": k.get("state"), "veteran": k.get("veteran"), "hero": k.get("hero"), } achievements = extract_achievements(k) ek["achievements"] = achievements ek["summary"] = format_achievement_line(achievements) results.append(ek) ranked = rank_kerbals_by_experience( [{"kerbal": r, "achievements_data": r["achievements"]} for r in results] ) # Print as table print(f"\n{'Rank':>4s} {'Name':28s} {'Trait':14s} {'State':10s} Experience Summary") print(f"{'='*4} {'='*28} {'='*14} {'='*10} {'='*80}") for i, entry in enumerate(ranked): ek = entry["kerbal"] ach = entry["achievements_data"] flags_count = len(ach["flags_planted"]) eva_count = len(ach["eva_bodies"]) # Build compact achievement string body_parts = [] for body in ach["bodies_visited"]: best = ach["highest_per_body"].get(body, "?") label = ACHIEVEMENT_LABELS.get(best, best) body_parts.append(f"{body}:{label}") ach_str = ", ".join(body_parts) if body_parts else "(none)" extras = [] if flags_count: extras.append(f"{flags_count} flag{'s' if flags_count > 1 else ''}") if eva_count: extras.append(f"EVAx{eva_count}") if extras: ach_str += " [" + ", ".join(extras) + "]" print( f"{i+1:4d} {ek['name'][:28]:28s} {ek['trait'] or '?':14s} " f"{ek['state'] or '?':10s} " f"{'V' if ek['veteran'] else ' '} {'H' if ek['hero'] else ' '} " f"F{ach['total_flights']:3d} B{ach['body_count']:2d} | {ach_str}" ) # JSON output as well if "--json" in flags: print("\n" + json.dumps([e["kerbal"] for e in ranked], indent=2, default=str, ensure_ascii=False))