Files
2026-06-05 16:19:37 +08:00

655 lines
22 KiB
Python

"""
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))