Documentation
¶
Overview ¶
Package save persists and restores the simulation World as JSON.
The on-disk envelope ships a richer header from day one — version, generator, clock_t0, body_catalog_hash, payload — so future schema migrations and the v0.6 multiplayer `session` block can land without bumping every caller. See designdocs/terminal-space-program/state-of-game.md §3 v0.4.0 for the rationale.
Saves directory API — v0.26 / ADR 0033. The single fixed save.json slot becomes a saves/ directory of flat, independent, nameable Saves. Filenames are opaque (§B — the player-facing name lives in the envelope's Meta header); the directory is the source of truth (§C — the browser scans + header-parses, no sidecar index); the quicksave and autosave lanes are reserved filenames that named saves can never collide with (§D/§E). No SchemaVersion bump (§J) — Meta is additive envelope bookkeeping, the Payload shape is untouched.
Index ¶
- Constants
- Variables
- func DefaultPath() (string, error)
- func Delete(id string) error
- func Load(path string) (*sim.World, error)
- func LoadID(id string) (*sim.World, error)
- func Overwrite(id string, w *sim.World) error
- func Rename(id, name string) error
- func Save(w *sim.World, path string) error
- func SavesDir() (string, error)
- func WriteAutosave(w *sim.World) error
- func WriteQuicksave(w *sim.World) error
- type ActiveBurn
- type Craft
- type DockedComponent
- type File
- type Focus
- type Lane
- type Meta
- type Node
- type Payload
- type SaveInfo
- type Stage
- type Target
- type Vec3
Constants ¶
const QuicksaveID = "quicksave.json"
QuicksaveID is the fixed quicksave-lane filename F5 always targets (ADR 0033 §D).
const SchemaVersion = 9
SchemaVersion is the on-disk version that Save writes today. v0.4.0 shipped v1; v0.6.0 bumped to v2 to add ManeuverNode.Event for the burn-at-next scheduler; v0.6.5 bumped to v3 to add Payload.Missions for the mission-scaffold slice; v0.7.6 bumped to v4 to add per-node Throttle. v0.8.0 left the version at 4 — the new RCS fields ride along as omitempty additions, with the loader filling defaults for older saves. v0.8.1 bumped to v5 — the first non-additive migration: `Craft *Craft` → `Crafts []Craft` + `ActiveCraftIdx`. v0.9.1 bumps to v6 — Craft.Stages becomes the source of truth for propulsion + mass; pre-v6 craft entries migrate by wrapping the v5 flat fields into a single-element Stages slice (see migrateV5Craft). Load accepts any version in [1, SchemaVersion]; pre-v6 envelopes are translated on load. Bumps that need real migration logic should add a dedicated upgrade pass keyed off File.Version. v0.14.x bumps to v7 — vessels gain a stable Spacecraft.ID and every target (world cursor, per-craft binding, planted-node + in-flight-burn target slots) references a craft by ID instead of slate index (ADR 0012, GH #87). v6 envelopes migrate on load via migrateV6PayloadToV7, which assigns IDs by slate position and rewrites the stored indices to IDs. v0.16 bumps to v8 (ADR 0015) — every Craft gains `system_idx`, the per-Vessel System binding. The v7→v8 migration derives each craft's SystemIdx from which loaded System contains its PrimaryID (Sol/0 fallback), so existing Sol craft and any craft spawned by the buggy interim Lumen build both migrate correctly; see migrateV7PayloadToV8. v0.21 bumps to v9 (ADR 0025) — the mission shape inverts from a single typed predicate to a Mission of ordered Objectives + campaign metadata. The v8→v9 migration re-seeds: it drops the old single-predicate progress so the load path reseeds the new ladder from the catalog; see migrateV8PayloadToV9.
Variables ¶
var ( ErrSchemaMismatch = errors.New("save: schema version mismatch") ErrCatalogMismatch = errors.New("save: body catalog hash mismatch") ErrCraftPrimary = errors.New("save: craft primary not found in loaded systems") )
Errors returned by Load.
var ErrReservedLane = errors.New("save: reserved quicksave/autosave lane")
ErrReservedLane is returned when a named-save operation (Overwrite, Rename) targets a reserved quicksave/autosave filename — the lanes are managed and never manually overwritable or renamable (ADR 0033 §D/§F).
Functions ¶
func DefaultPath ¶
DefaultPath returns the platform-appropriate save path. Honors $XDG_STATE_HOME on linux/macOS; falls back to ~/.local/state. Windows users can set $XDG_STATE_HOME explicitly.
func Delete ¶ added in v0.26.0
Delete removes the save id. Reserved lanes are deletable (ADR 0033 §F — they are managed, not precious); the caller confirms destructive actions.
func Load ¶
Load reads path, validates the envelope, and returns a fresh World hydrated from the payload. Errors with ErrSchemaMismatch on version drift, ErrCatalogMismatch on body-catalog drift, or ErrCraftPrimary when the craft references a primary that no longer exists.
func LoadID ¶ added in v0.26.0
LoadID fully hydrates the save id from the saves directory — the same validated Load path (schema range, catalog hash, migrations) as the legacy single slot.
func Overwrite ¶ added in v0.26.0
Overwrite rewrites the existing named save id in place with w, preserving its Meta.Name and refreshing the volatile fields (SavedAt, InGameEpoch, ActiveVesselName, SystemName). The target must exist — Overwrite is a browser action on a listed row, never a silent create — and must not be a reserved lane (§D/§F).
func Rename ¶ added in v0.26.0
Rename sets the save's display name — a pure Meta rewrite (ADR 0033 §B): same filename, Payload bytes carried through untouched as raw JSON, ClockT0 carried through so the rename doesn't reorder the list (SavedAt derives from it, so ordering is preserved for free). Reserved lanes refuse (§F). A Meta-less legacy file gains an empty Meta on rename to hold the name; the in-game date stays unknowable from the header and is left zero.
func Save ¶
Save serialises w to path, creating parent directories as needed. Atomic on POSIX: writes to a sibling tmpfile and renames into place.
This is the legacy single-slot writer — it stamps no Meta, keeping the pre-v0.26 envelope shape byte-compatible for downgraded binaries (ADR 0033 §G leaves the old save.json untouched as a safety net). The saves-directory lanes (saves.go) stamp Meta via the same buildFile + writeFileAtomic path.
func SavesDir ¶ added in v0.26.0
SavesDir returns the saves-directory path, $XDG_STATE_HOME/terminal-space-program/saves/ — the sibling of the legacy DefaultPath save.json (which is retained only for the first-run import probe, ADR 0033 §G).
func WriteAutosave ¶ added in v0.26.0
WriteAutosave writes w into the rotating autosave ring (ADR 0033 §E): a missing slot is filled before any rotation, otherwise the slot with the oldest ClockT0 is overwritten — and an unreadable ring file counts as the first victim (it is the least valuable thing in the ring, so it goes first).
func WriteQuicksave ¶ added in v0.26.0
WriteQuicksave writes w to the fixed quicksave lane (F5, ADR 0033 §D) — always quicksave.json, overwritten in place, never a named save. Full Meta is stamped minus a player name so the browser can still render the metadata columns.
Types ¶
type ActiveBurn ¶
type ActiveBurn struct {
Mode int `json:"mode"`
DVRemaining float64 `json:"dv_remaining"`
EndTimeNano int64 `json:"end_time_unix_nano"`
PrimaryID string `json:"primary_id"`
Throttle float64 `json:"throttle,omitempty"`
// TargetCraftIdx — retired pre-v7 slate idx (see Node.TargetCraftIdx);
// read-only for v6 migration. TargetCraftID (v7+, ADR 0012) is the
// burn's bound target stable ID, mirrored onto in-flight finite burns
// so a save mid-rendezvous-burn reloads still tracking its target.
TargetCraftIdx int `json:"target_craft_idx,omitempty"`
TargetCraftID uint64 `json:"target_craft_id,omitempty"`
// PlaneChangeRad / BurnDirUnit (v0.12.x, schema v6 additive) — mirror
// the ManeuverNode fields onto an in-flight burn so a save mid
// plane-change / BurnVector burn reloads with the direction intact.
PlaneChangeRad float64 `json:"plane_change_rad,omitempty"`
BurnDirUnit Vec3 `json:"burn_dir_unit,omitempty"`
}
ActiveBurn mirrors sim.ActiveBurn. Throttle (v0.7.6+, schema v4) is omitempty so v1–v3 saves with an in-flight burn round-trip cleanly: absent → 0.0 unmarshals → world.go's stepThrust defaults to 1.0 (the universal pre-v0.7.6 behaviour).
type Craft ¶
type Craft struct {
ID uint64 `json:"id,omitempty"` // v0.14.x / schema v7: stable Spacecraft.ID (ADR 0012). Pre-v7 saves omit it; migrateV6PayloadToV7 assigns one per slate position.
SystemIdx int `json:"system_idx,omitempty"` // v0.16 / schema v8: per-Vessel System binding (ADR 0015). Index into the name-sorted-Sol-first systems. Sol=0 omitted. Pre-v8 saves derive it from PrimaryID via migrateV7PayloadToV8. Distinct from Payload.SystemIdx (the world-level viewed system).
Name string `json:"name"`
DryMass float64 `json:"dry_mass"`
Fuel float64 `json:"fuel"`
Isp float64 `json:"isp"`
Thrust float64 `json:"thrust"`
PrimaryID string `json:"primary_id"`
R Vec3 `json:"r"`
V Vec3 `json:"v"`
M float64 `json:"m"`
Monoprop float64 `json:"monoprop,omitempty"`
MonopropCapacity float64 `json:"monoprop_capacity,omitempty"`
RCSThrust float64 `json:"rcs_thrust,omitempty"`
RCSIsp float64 `json:"rcs_isp,omitempty"`
LoadoutID string `json:"loadout_id,omitempty"`
Role string `json:"role,omitempty"`
Glyph string `json:"glyph,omitempty"`
Color string `json:"color,omitempty"`
// v0.9.1+: per-stage breakdown, bottom-first. omitempty so pre-
// v6 saves don't write the field and v6 saves of single-stage
// craft still wire it out for consumers that want stage-level
// detail.
Stages []Stage `json:"stages,omitempty"`
// v0.8.3+: docked-composite components for Undock to restore.
// Empty for non-composite craft.
DockedComponents []DockedComponent `json:"docked_components,omitempty"`
// v0.8.1+ — per-craft burn state. Pre-v5 saves had Nodes /
// ActiveBurn / etc. on the Payload (one shared list); the
// migration on load splits the singular into the active craft's
// fields.
Nodes []Node `json:"nodes,omitempty"`
ActiveBurn *ActiveBurn `json:"active_burn,omitempty"`
AttitudeMode int `json:"attitude_mode,omitempty"`
EngineMode int `json:"engine_mode,omitempty"`
// Target (v0.9.3 polish): per-craft target binding. Pre-polish
// saves had a single payload-level Target; the load path now
// migrates that into the active craft's slot when no per-craft
// targets are present. omitempty so legacy saves with no target
// AND fresh untargeted craft both round-trip without writing the
// field.
Target *Target `json:"target,omitempty"`
// PitchTrim (v0.9.2+, schema v6 additive): signed pitch-trim
// offset in radians applied on top of the active BurnMode.
// omitempty so legacy saves with no trim load with PitchTrim=0
// (= no trim, the v0.9.2-pre behaviour).
PitchTrim float64 `json:"pitch_trim,omitempty"`
// CurrentAttitudeDir (v0.10.0+, schema v6 additive): the craft's
// physical nose unit vector. Slew makes attitude load-bearing —
// a craft can be caught mid-slew — so the real nose must round-
// trip or a reload teleports it. Pre-v0.10.0 saves lack the key →
// decodes to a zero Vec3 → the slew integrator's first-tick snap
// guard seeds it from the commanded direction (no teleport, no
// slew-from-garbage). No schema bump (additive). SlewRate is NOT
// persisted — it is re-derived from the loadout on load.
CurrentAttitudeDir Vec3 `json:"current_attitude_dir,omitempty"`
// Landed (v0.9.2+, schema v6 additive): true when the craft is
// parked on its primary's surface co-rotating with the ground.
// Pre-v0.9.2 saves load with Landed=false (= normal integration,
// the v0.9.2-pre behaviour).
Landed bool `json:"landed,omitempty"`
// LaunchLatDeg / LaunchLonDeg (v0.9.2+, schema v6 additive):
// body-fixed (lat, lon) of the launchpad spawn. Only meaningful
// when Landed=true.
LaunchLatDeg float64 `json:"launch_lat_deg,omitempty"`
LaunchLonDeg float64 `json:"launch_lon_deg,omitempty"`
// Crashed / CanSoftLand / OnPad / LandedLatDeg / LandedLonDeg
// (v0.11.4+, schema v6 additive — no bump, per ADR 0004). All
// `omitempty`-default-false so pre-v0.11.4 saves round-trip
// cleanly: existing in-flight vessels load with Crashed=false /
// CanSoftLand=false / OnPad=false (= normal integration, no
// soft-land qualification, no auto-route gate), which matches
// pre-lifecycle behaviour. New vessels saved with these set
// restore the destructive / soft-landed / on-pad state on load.
Crashed bool `json:"crashed,omitempty"`
CanSoftLand bool `json:"can_soft_land,omitempty"`
OnPad bool `json:"on_pad,omitempty"`
LandedLatDeg float64 `json:"landed_lat_deg,omitempty"`
LandedLonDeg float64 `json:"landed_lon_deg,omitempty"`
// ChuteState (v0.12 Slice 3 / ADR 0008, schema v6 additive — no
// bump): the runtime parachute deploy state (0=Stowed, 1=Armed,
// 2=Deployed). omitempty so pre-Slice-3 saves round-trip without
// the field: absent ⇒ 0 ⇒ Stowed, correct for any vessel saved
// before this slice. The per-Stage HasParachute capability rides
// the Stage DTO; SyncFields re-derives the flat Spacecraft mirror.
ChuteState int `json:"chute_state,omitempty"`
// DecouplePlan (v0.12 Slice 2 / ADR 0007, schema v6 additive — no
// bump): the remaining bottom-up staging group sizes. omitempty so
// pre-v0.12 saves and craft with no plan (the common single-pop
// case) round-trip without writing the field: absent ⇒ nil ⇒
// single-pop. Persisted (not derived from the catalog) so a
// mission saved mid-staging — e.g. an Apollo Stack with S-IC
// already dropped, plan [1,1,2] remaining — restores the correct
// grouping for the still-pending LM extraction.
DecouplePlan []int `json:"decouple_plan,omitempty"`
}
Craft mirrors spacecraft.Spacecraft. Primary is referenced by ID; the rehydrated value is looked up across loaded systems on Load.
Monoprop / MonopropCapacity / RCSThrust / RCSIsp (v0.8.0+, schema v4) are omitempty so v1–v3 saves round-trip cleanly: absent → 0.0 in JSON, populated from spacecraft.DefaultRCSLoadout(DryMass) at load time so older saves inherit RCS without a migration.
Stages (v0.9.1+, schema v6) is the source of truth for propulsion + mass. Pre-v6 saves omit the field; the load path wraps the v5 flat fields (DryMass / Fuel / Isp / Thrust / Monoprop / etc.) into a single-element Stages slice via migrateV5Craft so the rehydrated Spacecraft has Stages populated regardless of save vintage. The flat fields stay on the wire for v6 too — they're derived shadow-mirror values that round-trip with the same numbers SyncFields would compute, so a v6 save loaded into a hypothetical v5 reader (none in production, but possible for tooling) would still see a coherent craft.
type DockedComponent ¶ added in v0.8.3
type DockedComponent struct {
Name string `json:"name"`
LoadoutID string `json:"loadout_id,omitempty"`
Role string `json:"role,omitempty"`
Glyph string `json:"glyph,omitempty"`
Color string `json:"color,omitempty"`
DryMass float64 `json:"dry_mass"`
FuelCapacity float64 `json:"fuel_capacity,omitempty"`
MonopropCapacity float64 `json:"monoprop_capacity,omitempty"`
Isp float64 `json:"isp,omitempty"`
Thrust float64 `json:"thrust,omitempty"`
RCSThrust float64 `json:"rcs_thrust,omitempty"`
RCSIsp float64 `json:"rcs_isp,omitempty"`
// CanSoftLand / HasParachute (v0.12 Slice 3 / ADR 0008, schema v6
// additive — no bump): the surface-arrival capability flags, so a
// composite saved with a chute-bearing or soft-land component
// restores those capabilities on undock after reload. omitempty;
// absent → false, matching pre-Slice-3 components.
CanSoftLand bool `json:"can_soft_land,omitempty"`
HasParachute bool `json:"has_parachute,omitempty"`
// Stages (v0.12 / ADR 0009, schema v6 additive — no bump): the
// component's full per-stage breakdown, so a multi-stage docked
// component (the Apollo LM = Descent + Ascent, or the SM+CM core
// after transposition) round-trips and Undock can rebuild it as a
// multi-stage craft. omitempty; absent → nil, which makes
// sim.Undock fall back to the legacy single-stage rebuild —
// matching every pre-ADR-0009 composite.
Stages []Stage `json:"stages,omitempty"`
// Owner / CraftID (v0.28 S5, ADR 0034 cross-player docking): the
// component's ownership provenance. Owner is a guest player's
// fingerprint when this component rides in the docker's stack; empty
// for a same-player composite. CraftID is the component's pre-dock
// stable ID, handed back on cross-player undock. Additive omitempty,
// no schema bump — absent → "" / 0, exactly a single-World composite,
// so every pre-v0.28 save and all local docking round-trips unchanged.
Owner string `json:"owner,omitempty"`
CraftID uint64 `json:"craft_id,omitempty"`
}
DockedComponent mirrors spacecraft.DockedComponent. v0.8.3+.
type File ¶
type File struct {
Version int `json:"version"`
Generator string `json:"generator"`
ClockT0 int64 `json:"clock_t0"`
BodyCatalogHash string `json:"body_catalog_hash"`
Meta *Meta `json:"meta,omitempty"`
Payload Payload `json:"payload"`
}
File is the on-disk envelope.
Meta (v0.26 / ADR 0033, schema v9 additive — NO bump, §J) is the saves-browser header: display name, wall-clock SavedAt, in-game epoch, active vessel, system. nil for legacy envelopes (the single-slot Save writer stamps none) — a v9 save lacking Meta still loads, and ReadHeader derives SavedAt from ClockT0 for ordering. SchemaVersion tracks the Payload shape; envelope-level bookkeeping rides along additively.
type Lane ¶ added in v0.26.0
type Lane string
Lane classifies a saves-directory entry by its reserved-filename namespace (ADR 0033 §D): named saves are written only by explicit Save-As; quicksave/autosave are managed lanes that never collide with them.
type Meta ¶ added in v0.26.0
type Meta struct {
Name string `json:"name,omitempty"`
InGameEpoch time.Time `json:"in_game_epoch,omitzero"`
ActiveVesselName string `json:"active_vessel_name,omitempty"`
SystemName string `json:"system_name,omitempty"`
// SavedAt is the wall-clock write time — but it is NOT persisted
// (json:"-"). The envelope's ClockT0 is the single on-disk source of
// that timestamp; SavedAt is DERIVED from it on every read (ReadHeader
// / List / the import), so assigning this field has no on-disk effect.
// It stays on Meta only as the browser's ready-to-render sort +
// display value. (Pre-v0.26.0 the two were persisted side by side —
// pure duplication; de-duplicated before release, ADR 0033.)
SavedAt time.Time `json:"-"`
}
Meta is the envelope header the Saves browser lists games by — readable via ReadHeader without hydrating a World or touching the body catalog (ADR 0033 §C). All fields are additive/omit-on-zero so a pre-v0.26 v9 envelope (no meta key) still loads (§J); the legacy single-slot Save writer stamps no Meta at all.
InGameEpoch is the simulation clock (Payload.SimTimeNano) at save time. Name is the player-facing Save name; reserved lanes (quicksave/autosave) leave it empty and are labelled by Lane instead.
func ReadHeader ¶ added in v0.26.0
ReadHeader returns the envelope's Meta without hydrating the Payload: no World rebuild, no body-catalog load or hash check (ADR 0033 §C — the browser lists N files this way). SavedAt is always derived from the envelope's ClockT0 (wall-clock save time — the single source of truth, never persisted on Meta itself), so a stamped and a Meta-less pre-v0.26 envelope order identically. The in-game date is unknowable from a Meta-less header and InGameEpoch stays zero — only a full Payload read (Load) can recover it.
type Node ¶
type Node struct {
TriggerTimeNano int64 `json:"trigger_time_unix_nano"`
Mode int `json:"mode"`
DV float64 `json:"dv"`
DurationNano int64 `json:"duration_nano"`
PrimaryID string `json:"primary_id"`
Event int `json:"event,omitempty"`
Throttle float64 `json:"throttle,omitempty"`
// TargetCraftIdx is the retired pre-v7 one-based slate idx the node
// was bound to. Retained only to read v6 saves; migrateV6PayloadToV7
// converts it to TargetCraftID.
TargetCraftIdx int `json:"target_craft_idx,omitempty"`
// ID (v0.16, ADR 0016) is the node's stable identity. Additive
// omitempty — older saves omit it and load as 0; EnsureNodeIDs
// back-fills on load. No schema bump (same precedent as PlaneChangeRad).
ID uint64 `json:"id,omitempty"`
// TargetCraftID (v0.14.x / schema v7, ADR 0012) is the bound target
// craft's stable Spacecraft.ID. Zero = no target. v7 saves write
// this and leave TargetCraftIdx zero.
TargetCraftID uint64 `json:"target_craft_id,omitempty"`
// PlaneChangeRad (v0.12.x, schema v6 additive) — the signed rotation
// angle for a BurnPlaneChange node (the `I` inclination plant + the
// Slice 5 split-strategy plane change). Pre-v0.12 saves omit it;
// absent → 0, the correct value for non-plane-change nodes. Was
// dropped on save before v0.12 (a latent v0.10.4 gap); the split
// strategy makes it load-bearing, so it now round-trips.
PlaneChangeRad float64 `json:"plane_change_rad,omitempty"`
// BurnDirUnit (v0.12.x, schema v6 additive) — the captured inertial
// thrust direction for a BurnVector node (the fused-Lambert combined
// departure). Additive/omitempty, following the CurrentAttitudeDir
// precedent; absent → zero vector for non-BurnVector nodes. No migration.
BurnDirUnit Vec3 `json:"burn_dir_unit,omitempty"`
}
Node mirrors sim.ManeuverNode. Event (v0.6.0+, schema v2) is omitempty so v1 saves round-trip cleanly: the field is absent on disk and unmarshals to zero (TriggerAbsolute), which matches the pre-v0.6 behaviour. v2 saves with non-zero Event encode the integer directly. Throttle (v0.7.6+, schema v4) is omitempty so v1–v3 saves round-trip cleanly — absent → 0.0 in JSON, mapped to 1.0 (full throttle, the prior universal behaviour) in worldFromPayload.
type Payload ¶
type Payload struct {
SystemIdx int `json:"system_idx"`
SimTimeNano int64 `json:"sim_time_unix_nano"`
BaseStepNano int64 `json:"base_step_nano"`
WarpIdx int `json:"warp_idx"`
Paused bool `json:"paused"`
Focus Focus `json:"focus"`
Target *Target `json:"target,omitempty"` // v0.9.0+ unified target slot. nil pointer (zero/None) → omitted on the wire.
Craft *Craft `json:"craft,omitempty"` // v1–v4 singular form; migrated to Crafts on load.
Crafts []Craft `json:"crafts,omitempty"`
ActiveCraftIdx int `json:"active_craft_idx,omitempty"`
NextCraftID uint64 `json:"next_craft_id,omitempty"` // v0.14.x / schema v7: monotonic craft-ID counter (ADR 0012).
NextNodeID uint64 `json:"next_node_id,omitempty"` // v0.16: monotonic node-ID counter (ADR 0016). Additive omitempty, no schema bump; EnsureNodeIDs reprimes on load.
Nodes []Node `json:"nodes,omitempty"`
ActiveBurn *ActiveBurn `json:"active_burn,omitempty"`
Missions []missions.Mission `json:"missions,omitempty"`
}
Payload carries the live simulation state. Anything derivable from the catalog (Systems, Calculator) is reconstructed on Load.
v0.8.1 / schema v5: `Craft *Craft` (singular pointer) replaced by `Crafts []Craft` (slice) + `ActiveCraftIdx`. Pre-v5 saves with a non-nil singular `Craft` field are translated by `migrateV4ToV5` in save_migrate.go on load.
type SaveInfo ¶ added in v0.26.0
type SaveInfo struct {
ID string
Meta Meta
Lane Lane
Unreadable bool
Note string // human-facing reason when Unreadable (e.g. "newer version", "corrupt")
}
SaveInfo is one saves-directory listing entry: the opaque filename (the stable ID every targeted operation takes), its parsed Meta, and which lane the filename falls in.
Unreadable flags an entry whose header would not parse — a corrupt file, or one written by a newer build (a post-downgrade schema-v(N+1) save). List surfaces these rather than dropping them (ADR 0033 §C): silently hiding a file makes a directory that clearly holds saves read as empty, and — worse — a newer-build save would vanish on a downgrade with no trace. The browser renders them dimmed and non-loadable, with Note as the reason; SavedAt/Meta are zero.
func ImportLegacyIfNeeded ¶ added in v0.26.0
ImportLegacyIfNeeded migrates the legacy single-slot save.json into the saves directory as a named save — once. It runs whenever the import has not yet settled (the marker is absent) AND a legacy save.json is present; a successful import drops the marker so it never repeats, and a FAILED import leaves the marker absent so the next startup retries (the marker, not the mere existence of saves/, is what gates the retry). The legacy file is read, never rewritten or deleted — it stays behind untouched as a downgrade safety net (§G: a pre-v0.26 binary still reads it).
The default name derives from the save's IN-GAME date, which lives in Payload.SimTimeNano — hence the one full unmarshal of the one legacy file. ClockT0 is wall-clock save time, not the in-game date, and must not be substituted; SavedAt derives from it (in memory only — Meta.SavedAt is never persisted) so the imported entry sorts truthfully in the browser. The payload bytes are carried through raw, Rename-style, with the original schema Version riding along untouched — LoadID migrates it on load like any older save.
Returns the imported entry and true when the migration ran; the zero SaveInfo and false when there was nothing to do.
func List ¶ added in v0.26.0
List scans the saves directory and header-parses every entry, newest SavedAt first (ties broken by filename for determinism). A missing directory lists as empty — first run, before any save exists. A .json file whose header will not parse (corrupt, or a newer-build schema version after a downgrade) is surfaced as an Unreadable entry rather than silently dropped (§C) — it sorts to the bottom on a zero SavedAt and the browser shows it dimmed and non-loadable. Only non-.json strays and tmpfiles are skipped.
type Stage ¶ added in v0.9.1
type Stage struct {
LoadoutID string `json:"loadout_id,omitempty"`
Name string `json:"name,omitempty"`
Glyph string `json:"glyph,omitempty"`
Color string `json:"color,omitempty"`
DryMass float64 `json:"dry_mass,omitempty"`
FuelMass float64 `json:"fuel_mass,omitempty"`
FuelCapacity float64 `json:"fuel_capacity,omitempty"`
Thrust float64 `json:"thrust,omitempty"`
Isp float64 `json:"isp,omitempty"`
MonopropMass float64 `json:"monoprop_mass,omitempty"`
MonopropCap float64 `json:"monoprop_cap,omitempty"`
RCSThrust float64 `json:"rcs_thrust,omitempty"`
RCSIsp float64 `json:"rcs_isp,omitempty"`
BallisticCoefficient float64 `json:"ballistic_coefficient,omitempty"`
// CanSoftLand (v0.11.4-followup, schema v6 additive — no bump
// per ADR 0004): per-Stage soft-land flag, round-tripped on the
// wire so a saved Falcon-9 S1 (or Apollo-Stack Lander stage)
// loads with the right surface-arrival-predicate gate even after
// SyncFields re-derives the flat Spacecraft.CanSoftLand mirror.
// Pre-v0.11.4 saves load with the field absent → default-false,
// which matches every pre-v0.11.4 catalog stage.
CanSoftLand bool `json:"can_soft_land,omitempty"`
// HasParachute (v0.12 Slice 3 / ADR 0008, schema v6 additive — no
// bump): per-Stage parachute capability, round-tripped on the wire
// so a saved capsule (or the Apollo CSM stage) loads with the right
// chute-route gate after SyncFields re-derives the flat
// Spacecraft.HasParachute mirror. Pre-Slice-3 saves load with the
// field absent → default-false, matching every pre-Slice-3 stage.
HasParachute bool `json:"has_parachute,omitempty"`
// CommandSource / Antenna (v0.23 / ADR 0027, schema v9 additive — NO
// bump): per-Stage comms attributes, round-tripped so a saved probe
// or relay sat reloads with its connectivity role intact. Pre-comms
// saves load with these absent → empty, and the load-time
// EnsureCommandSource backfill stamps a default command source on the
// surviving core so old vessels stay controllable.
//
// AntennaRangeM is a rated range in metres (v0.22.x combinability model,
// ADR 0027 §2 amendment), renamed from the original antenna_power_w. A
// pre-amendment save carries antenna_power_w (now an ignored key) → this
// field loads as 0; wireStagesToSim backfills the rated range from the
// antenna kind so those saves keep working without a schema bump.
CommandSource string `json:"command_source,omitempty"`
AntennaKind string `json:"antenna_kind,omitempty"`
AntennaRangeM float64 `json:"antenna_range_m,omitempty"`
}
Stage mirrors spacecraft.Stage on the wire. v0.9.1+. All numeric fields are omitempty so a single-stage craft with default RCS pool + zero monoprop residual still serialises compactly.
type Target ¶ added in v0.9.0
type Target struct {
Kind int `json:"kind"`
BodyIdx int `json:"body_idx,omitempty"`
CraftIdx int `json:"craft_idx,omitempty"` // pre-v7 (read-only for migration)
CraftID uint64 `json:"craft_id,omitempty"` // v7+ stable ID
}
Target mirrors sim.Target by value. v0.9.0+. The zero value (Kind=0=TargetNone, BodyIdx=0) is suppressed by the payload's `omitempty` tag, so saves predating v0.9.0 round-trip without writing the field — and load fills sim.World.Target with the zero value, matching pre-target behaviour.
CraftID (v0.14.x / schema v7, ADR 0012) is the target craft's stable Spacecraft.ID. CraftIdx is the retired pre-v7 0-based slate index, retained only to read v6 saves; migrateV6PayloadToV7 converts it to CraftID. v7 saves write CraftID and leave CraftIdx zero.