diff --git a/scripts/parse_sfs.py b/scripts/parse_sfs.py new file mode 100644 index 0000000..3e3a568 --- /dev/null +++ b/scripts/parse_sfs.py @@ -0,0 +1,654 @@ +""" +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))