In-game Kerbal Space Program 1.12.5 plugin that captures vessel and kerbal history events as they happen, persists them as append-only JSONL journals, and exposes them over MQTT (live event stream + retained per-entity snapshots) and HTTP (queryable indexed history, plus a Server-Sent-Events live stream and a bundled mission-control test console).
Companion to:
- KSPBridge — real-time MQTT vessel telemetry (current state).
- KSPExternalVesselUtilities (KSPEVU) — vessel geometry export to glTF over HTTP+MQTT (what the ship looks like).
The three together let any consumer assemble what's happening now
(KSPBridge), what the ship looks like (KSPEVU), and what the ship
has done (KSPEVH) for a single vessel — keyed on the same
Vessel.id / persistentId correlation fields, all over one MQTT
broker.
The plugin builds, loads, hooks GameEvents, and writes JSONL
journals + retained MQTT snapshots. It is save-aware (per-save
physical separation), branch-aware (each quickload/revert starts a
new branch so reload chronology stays clean), enriches every recorded
event with geo / motion / orbit / state context, and ships two
self-contained browser views: the mission-control test page at
http://localhost:8091/ and the astronaut career page at
http://localhost:8091/career.html.
Each kerbal snapshot now carries identity (trait, experienceLevel,
gender), classification (type, rosterStatus), and live position
(currentVesselPid, currentVesselName, currentBody,
currentSituation, biome, latitude, longitude, altitude)
fields lifted directly from the live CrewRoster and game state.
Each vessel snapshot carries the matching geo / motion / orbit /
state set so consumers can query "where is X right now and what
state is it in" without scanning the per-event JSONL.
Coverage of the full event catalogue is still partial — high-value events (observe, launch, situation change, SOI change, staging, recovery, crash, crew board / EVA / transfer / killed, career milestones, vessel destroy) are wired up; lineage (decouple/couple), science, maneuvers, and several kerbal-career events remain TODO.
Not yet ready for end-user installation. Code is public for review and feedback.
Every event records:
ut— in-game universe time (seconds).realTs— wall-clock UTC ISO 8601 timestamp.type— one of the event-type constants.vesselPid+vesselPersistentId— vessel correlation keys matching KSPBridge's wire format.crew[]— kerbal names involved (full vessel roster on vessel-state events; subject only on transitions).data{}— event-specific payload (body name, situation, altitude, experiment id, etc.).branch— branch identifier within the active save (see below).category— one of"vessel","crew","transition"— determines which journal file the event lands in.
Vessel events covered today: vessel_observed, vessel_launched,
vessel_situation_changed, vessel_soi_changed,
vessel_stage_activated, vessel_recovered, vessel_destroyed,
vessel_crashed.
Kerbal-transition events: kerbal_boarded, kerbal_eva,
kerbal_transferred, kerbal_died.
Cross-cutting: progress_completed (career milestones).
Planned: vessel_decoupled / vessel_coupled (lineage graph from
onPartCouple / onPartUndock), vessel_renamed,
science_collected, maneuver_executed, kerbal_recruited /
kerbal_level_changed / kerbal_stranded / kerbal_recovered.
Save awareness. Every save is fully isolated on disk. KSP saves
named default, Career_Hard, Sandbox_Test each get their own
subdirectory under data/saves/. Vessel pids and kerbal names that
overlap between saves (very common — Jebediah Kerman shows up in
nearly every save) never share files. The plugin tracks
HighLogic.SaveFolder via the onGameStateLoad hook and routes
journal writes accordingly.
Branching. When you quickload (F9), revert flight, or load a
different save, KSP rewinds in-game time. Without branch awareness
the journal would have events at UT=1100 followed by events at
UT=1050, and "what happened most recently" would be ambiguous. The
plugin solves this by treating each reload as a new branch:
currentBranch increments, every subsequent event carries the new
branch id, snapshots reflect only the current branch. Older-branch
events stay in the JSONL files forever (append-only, crash-safe) but
are filtered out of "current state" projections. Branch boundaries
are recorded in index.json.branches with the real wall-clock
timestamp and reason ("initial", "game_state_load", or
"ut_rollback_inferred" from the safety-net heuristic).
Event categorization. Each emitted event carries a category
that determines which logs receive it:
| Category | Example types | Filed to |
|---|---|---|
vessel |
observed, launched, situation_changed, soi_changed, stage_activated | vessel JSONL only |
crew |
recruited, level_changed, stranded, observed | kerbal JSONL only |
transition |
boarded, eva, transferred, died, recovered/crashed/destroyed when crewed | both vessel and kerbal JSONLs |
Cross-filing is bounded by transition count (≤20 per typical mission), so per-kerbal queries stay fast (single-file linear read) without paying duplication tax on every staging or situation-change event. Vessel-state events with crew aboard still update the kerbal snapshot (so kerbals appear in the index the moment their ship is observed) without copying the event line into the kerbal JSONL.
crewHistory on vessel snapshots and vesselsServed on kerbal
snapshots maintain the timeline join key — open entries (toUt: null) mean "currently aboard"; transitions close them with the
reason (eva, transferred, died, recovered, destroyed).
Two flavours, parallel to KSPBridge's pattern.
| Channel | Topic / Path | Retained | Purpose |
|---|---|---|---|
| MQTT | ksp/history/event |
no | Streaming, one message per event. |
| MQTT | ksp/history/vessel/<pid>/snapshot |
yes | Current vessel summary. |
| MQTT | ksp/history/vessel/<pid>/recent |
yes | Last N vessel events. |
| MQTT | ksp/history/kerbal/<name>/snapshot |
yes | Current kerbal summary. |
| MQTT | ksp/history/kerbal/<name>/recent |
yes | Last N kerbal events. |
| MQTT | ksp/history/_bridge/status |
yes | Online flag + version, with LWT on disconnect. |
| HTTP | GET / |
— | Bundled mission-control test page (HTML). |
| HTTP | GET /health |
— | Plugin status, current save + branch, MQTT/SSE counts. |
| HTTP | GET /saves.json |
— | List of every save we have data for. |
| HTTP | GET /save/<save>/health |
— | Save-scoped health. |
| HTTP | GET /save/<save>/vessels.json |
— | Vessel index for one save. |
| HTTP | GET /save/<save>/kerbals.json |
— | Kerbal index for one save. |
| HTTP | GET /save/<save>/vessel/<pid> |
— | Vessel snapshot. |
| HTTP | GET /save/<save>/vessel/<pid>/events?branch=N&limit=N |
— | Recent events (JSON array, branch-filterable). |
| HTTP | GET /save/<save>/vessel/<pid>/events.jsonl |
— | Raw JSONL stream of all events ever. |
| HTTP | GET /save/<save>/kerbal/<name> |
— | Kerbal snapshot. |
| HTTP | GET /save/<save>/kerbal/<name>/events?branch=N&limit=N |
— | Kerbal-side events. |
| HTTP | GET /save/<save>/kerbal/<name>/events.jsonl |
— | Raw JSONL. |
| HTTP | GET /save/<save>/kerbal/<name>/full?branch=N |
— | Server-side join: kerbal log + each served vessel's slice, UT-ordered. |
| HTTP | GET /save/<save>/kerbal/<name>/portrait.png |
— | Captured in-flight portrait, or bundled silhouette fallback. |
| HTTP | GET /stream |
— | Server-Sent Events live push of every recorded event. |
The unprefixed routes (/vessels.json, /kerbal/<name>, etc.)
default to the active save. The /save/<name>/... prefix lets you
address any save on disk, current or not.
Bulky reads go through HTTP; MQTT carries small notifications and
retained per-entity summaries. Same split-of-roles KSPEVU uses for
geometry (.glb over HTTP, ready notification over MQTT).
Whenever a kerbal appears in KSP's portrait gallery (bottom-right of the
Flight scene during a crewed mission), the plugin captures the live
RenderTexture, encodes it as PNG, and writes it to
data/saves/<save>/kerbals/portraits/<name>.png. Capture is per-save —
a "Jeb" in save A and a "Jeb" in save B keep their own files.
GET /save/<save>/kerbal/<name>/portrait.png returns the captured PNG if
present, otherwise a server-side fallback (WebRoot/img/no-portrait.svg,
a kerbal-shaped silhouette with "NO IMAGE" caption). The route never
404s — the client <img> always renders something.
Coverage is "any kerbal you've flown with" — which is the same population that would show up on any career-style page. Recruited-but-unflown kerbals get the silhouette until their first mission.
http://localhost:8091/ serves a self-contained HTML console with:
- Status header — HTTP / MQTT / SSE LEDs, current save name, current branch id, uptime, vessels & kerbals tracked, events received.
- Vessels panel — clickable list of every vessel observed in the active save's current branch.
- Kerbals panel — clickable list of every kerbal seen, even if the only event for them so far is "observed aboard a vessel."
- Live event ticker — Server-Sent Events stream, newest at top, with category-coded color (cyan for vessel, gold for transition).
- Detail panel — snapshot fields plus the entity's last 20 events.
CAREERlink in the header to the astronaut career page.
Useful as a smoke test, a debug surface, and a reference implementation for any consumer of the HTTP/SSE API.
http://localhost:8091/career.html serves a different view — a grid
of trading-card-style profiles, one per kerbal in the active save.
Cards deal in from above with a staggered 3D animation, settle into
the grid, and flip on click to reveal a mission record on the back.
- Card front: portrait (captured from the in-flight gallery, or
the silhouette fallback if not yet captured), name, a cyan
JOB · LEVEL · ♂/♀ role line (e.g.
PILOT · LV 3 · ♂), the most recent event, total event count, current UT. - Card back: Job / Level / Gender / Vessels served / Mission days
/ Total events / Last event / Last seen, plus the per-vessel
vesselsServed[]history (vessel name + duration + departure reason) and a status badge (ACTIVE / RECOVERED / KIA / UNASSIGNED derived fromvesselsServedopen/close state). - Filter toolbar: two filter rows (TYPE: All / Crew / Tourist /
Applicant / Unowned, STATUS: All / Available / Assigned / Dead /
Missing), AND-combined across rows, multi-select within each row.
An ACTIVE preset chip resets to Crew + (Available, Assigned).
Selection persists across reloads via
localStorage. - Section grouping: GROUP BY: VESSEL (default — vessel name as amber section header, "In orbit around Duna" / "Landed in Mun Highlands" as a cyan sublabel) or BODY (Kerbin first, then Mun / Minmus / etc) or NONE (flat grid). Kerbals not currently aboard a vessel land in a "Kerbal Space Center" section at the bottom.
RELOADrefetches the roster;FLIP ALLtoggles every card simultaneously.
Roster comes from GET /kerbals.json; portraits from
GET /kerbal/<name>/portrait.png; back-of-card details lazy-loaded
from GET /kerbal/<name> on first flip.
Every kerbal and vessel snapshot now carries typed top-level fields
lifted from the live game state. Consumers can filter, group, and
display without scanning the per-event JSONL or chasing fields out
of the latest event's data dict.
KerbalSnapshot / KerbalSummary (also surfaced in
/kerbals.json and /save/<save>/kerbals.json):
- Identity:
trait("Pilot" / "Engineer" / "Scientist" / "Tourist"),experienceLevel(0-5 stars),gender("Male" / "Female"). - Classification:
type(KerbalTypeenum — Crew / Tourist / Applicant / Unowned),rosterStatus(RosterStatusenum — Available / Assigned / Dead / Missing). - Live position:
currentVesselPid,currentVesselName,currentBody,currentSituation(LANDED / ORBITING / FLYING / etc),biome,biomeDisplay,latitude,longitude,altitude. - Off-vessel kerbals (Available at KSC, Dead, Missing) have null current-position fields so consumers can distinguish "currently flying" from "in standby".
VesselSnapshot / VesselSummary (also surfaced in
/vessels.json and /save/<save>/vessels.json):
- Identity:
vesselName,currentBody. - Geo:
biome,biomeDisplay,latitude,longitude,altitude. - Motion:
surfaceSpeed,orbitalSpeed,verticalSpeed. - Orbit:
apoapsis,periapsis,inclination,eccentricity,period. - State:
mass,currentStage,liquidFuel,oxidizer,electricCharge,monoPropellant. - Numeric fields are nullable (
double?/int?) so consumers can distinguish "never observed for this vessel" from "observed and 0".
Population: every event write copies enrichment from the event's
data dict into the typed snapshot fields (preserve-on-absent —
events that don't carry a key keep the prior snapshot value, never
blanking it). On every save load, EnrichKerbalSnapshotsFromRoster
and EnrichVesselSnapshotsFromGameState walk all snapshot files and
rewrite from live CrewRoster / flightState.protoVessels /
FlightGlobals.Vessels. Both passes are idempotent.
Nine event types now record full vessel context in their data
payload via the EventEnrichment helper. The same field names are
used across every event so consumers don't have to branch on event
type.
| Event | Geo | Motion | Orbit | State |
|---|---|---|---|---|
vessel_observed |
✓ | ✓ | ✓ | ✓ |
vessel_situation_changed |
✓ | ✓ | ✓ | ✓ |
vessel_launched |
✓ | ✓ | ✓ | |
vessel_crashed |
✓ | ✓ | ✓ | |
kerbal_died |
✓ | ✓ | ✓ | |
vessel_soi_changed |
✓ | ✓ | ||
vessel_recovered |
✓ | |||
vessel_destroyed |
✓ | ✓ | ||
vessel_stage_activated |
✓ |
Field set:
- Geo:
biome,biomeDisplay,latitude,longitude,altitude,radarAltitude. - Motion:
surfaceSpeed,orbitalSpeed,verticalSpeed,mach(when in atmosphere),gForce(when non-zero). - Orbit:
apoapsis,periapsis,inclination,eccentricity,period. - State:
mass,currentStage,liquidFuel,oxidizer,electricCharge,monoPropellant.
These fields are persisted in the per-event JSONL forever (canonical historical record) and surface on the latest snapshot via the promoted typed fields described above. Adding a new field to the helper is one line — every opted-in handler gets it for free.
GameData/KSPEVH/data/
└── saves/
├── <save_name>/
│ ├── index.json vessels[], kerbals[], branches[], currentBranch
│ ├── vessels/
│ │ ├── <pid>.json snapshot — current state only
│ │ └── <pid>.events.jsonl append-only event log (all branches)
│ └── kerbals/
│ ├── <name>.json snapshot — current state only
│ ├── <name>.events.jsonl career-only events (board/EVA/transfer/die/etc.)
│ └── portraits/
│ └── <name>.png captured in-flight portrait (one per kerbal)
├── <other_save>/...
└── _premigration/ pre-save-aware data, if you upgraded from v0.1.x
Each <save_name> is the raw HighLogic.SaveFolder from KSP. To
copy a save's history to another machine, copy that one
subdirectory. To wipe a save without losing the others, delete it.
JSONL (one event per line, no commas, no array wrapping) was chosen over a single big JSON because:
- Append is constant-cost regardless of log size.
- Survives partial writes — a crash mid-write corrupts only the last line, not the entire log.
- Streams cleanly over HTTP for consumers that want incremental reads.
A future patch may add a SQLite mode for very long-running saves; the JSONL format stays as the canonical interchange format and default storage.
Everything on disk is plain text. Anyone with PowerShell, Notepad,
or cat can investigate. Snapshots (.json) are pretty-printed
single objects; event logs (.events.jsonl) are one JSON object per
line. Common queries:
List every event ever recorded for a vessel:
Get-Content "data\saves\<save>\vessels\<pid>.events.jsonl"
Filter by event type:
Select-String -Path "data\saves\<save>\vessels\*.events.jsonl" `
-Pattern '"type":"vessel_situation_changed"'
Limit to the current branch (consult index.json.currentBranch for
the value):
Get-Content "data\saves\<save>\vessels\<pid>.events.jsonl" |
Where-Object { $_ -match '"branch":2' }
Find a kerbal's events in a UT range:
Get-Content "data\saves\<save>\kerbals\Jebediah Kerman.events.jsonl" |
Where-Object {
$e = ($_ | ConvertFrom-Json)
$e.ut -ge 1000 -and $e.ut -le 2000
}
See branch boundaries (when each branch started, why):
(Get-Content "data\saves\<save>\index.json" | ConvertFrom-Json).branches
Find which vessel a kerbal was on at a given UT — open
kerbals/<name>.json and walk vesselsServed for the entry whose
fromUt ≤ targetUt < toUt (or where toUt is null, meaning still
aboard).
Find who was on a vessel at a given UT — same thing in reverse, walk
vessels/<pid>.json's crewHistory.
GameData/KSPEVH/Settings.cfg:
KSPEVH
{
broker_host = localhost
broker_port = 1883
topic_prefix = ksp/history
client_id = kspevh
http_port = 8091
data_dir = data
recent_events_count = 50
}
Defaults assume Mosquitto on the same machine as KSP. For a remote
broker, point broker_host at its hostname or IP. The
recent_events_count value caps the in-memory ring buffer driving
the MQTT recent topic and the default page size for HTTP
/events routes; the on-disk JSONL is unbounded.
Same conventions as KSPBridge / KSPEVU: requires Visual Studio 2022
or 2026 with .NET desktop development workload + .NET Framework
4.7.1 targeting pack. dotnet build -c Release works too. Post-build
target deploys DLLs + WebRoot/index.html + Settings.cfg (only if
missing) + KSPEVH.version + LICENSE + THIRD-PARTY-NOTICES.md
to <KSP install>/GameData/KSPEVH/.
JSON serialization is handled by an embedded copy of MiniJSON (Calvin
Rien, MIT, ~330 lines in src/KSPEVH/History/MiniJson.cs). KSPEVH
does not depend on Newtonsoft.Json — Unity 2019.4's Mono runtime
cannot resolve System.Runtime.Serialization v4.0.0.0 which
Newtonsoft.Json's JsonWriter static constructor requires. MiniJSON
has zero external dependencies, so the problem cannot recur.
MIT. See LICENSE.
Bundled libraries: see THIRD-PARTY-NOTICES.md.