phase 1c-extract: typed kRPC service client + SpaceCenter extract

Built the missing piece that connects the ksp-bridge to a real KSP
instance via kRPC. This adds a typed service client on top of the
existing KRPCClient, plus the SpaceCenter-specific extraction logic
that pulls the universe state from a running KSP save.

@kerbal-rt/krpc-client
- types.ts — runtime representation of kRPC Type descriptors
  (TypeCode enum, KrpcType interface, decodeKrpcType, typeName)
- decoder.ts — kRPC value codec: primitive decode/encode, class refs,
  enums, collections (LIST/SET/TUPLE/DICTIONARY), system messages
  (STATUS/SERVICES/STREAM/EVENT/PROCEDURE_CALL). 34 unit tests.
- services.ts — ServiceCache built from KRPC.GetServices() response.
  Lookup by (service, procedure), enum value/name resolution. 12 tests.
- service-client.ts — KrpcServices: high-level invoke-by-name client.
  loadServices() helper to connect + load catalog. 9 integration tests
  with a mock kRPC server.
- schema.ts — added Set/Dictionary/Event/Expression types so the
  decoder can handle system messages without external .proto files.
  Also fixed a bug where 'STREAM' was being encoded as 0 due to
  protobufjs's nested-enum string lookup.

ksp-bridge
- extract.ts — the actual SpaceCenter calls. ~280 procedure calls
  per poll for a typical KSP save (UT, bodies, vessels, then per-body
  and per-vessel class methods in parallel). Build the UniverseSnapshot.
- krpc-adapter.ts — rewrote to use KrpcServices (typed) instead of
  the stub extract function. Supports an optional injected services
  for testing.
- bridge.ts — uses the new ExtractedState type and buildSnapshot.
- index.ts — connects to kRPC; falls back to mock mode if no server.
- convert.ts — backward-compat shim, re-exports from extract.ts.

The ksp-bridge can now talk to a real KSP install. We do NOT need
the kRPC mod's .proto files on disk — the server's GetServices()
response is the source of truth for type info. Documented the full
list of procedures we call, the kRPC value encoding, and the new
architecture in ksp/README.md.

Tests: 119 total, all green. Typecheck and build clean across all 11
projects.

Bonus: fixed an integer-overflow bug in the krpc-client connect()
handshake (was passing 'RPC'/'STREAM' strings to protobufjs; its
nested-enum string lookup silently encodes as 0, which made the
stream handshake send the wrong type. Switched to numeric codes.)
This commit is contained in:
Mavis
2026-06-02 22:02:26 +00:00
parent 68bc7015fd
commit aebee77843
18 changed files with 2851 additions and 512 deletions
+163 -212
View File
@@ -1,229 +1,180 @@
# KSP-side Telemetry Bridge
# KSP ↔ kRPC integration
The `@kerbal-rt/ksp-bridge` package connects a running KSP instance (via
kRPC) to the kerbal-rt API. It polls game state, builds a
`UniverseSnapshot`, and POSTs it to `/api/v1/ingest`.
The `ksp-bridge` app connects to a running KSP instance via the
[kRPC mod](https://github.com/krpc/krpc) and pushes state to the
kerbal-rt API. This README documents what's wired up today and what's
still TODO.
> **Status: Phase 1c — implemented (mock mode) + full kRPC wiring ready.**
>
> - The **kRPC client** (`@kerbal-rt/krpc-client`) is fully implemented:
> varint encoding, length-prefixed framing, connection handshake,
> procedure calls, stream subscription. Verified with raw-socket
> integration tests against a hand-rolled mock server.
> - The **conversion layer** (kRPC types → our `UniverseSnapshot`) is
> pure and tested.
> - The **main bridge loop** (poll → convert → POST to API) is fully
> working. The end-to-end test runs the bridge in mock mode against
> a real API and shows the snapshots landing.
> - The **SpaceCenter.Vessel / CelestialBody / Orbit protobuf
> decoding** is the remaining piece. The kRPC mod ships the .proto
> files at runtime; the bridge can either:
> 1. **Load them dynamically** with protobufjs at startup (preferred)
> 2. **Ship a hand-written subset** of the relevant .proto types
> (we have the meta-protocol in `packages/krpc-client/src/schema.ts`)
## Quick start
---
## How the pieces fit
```
┌───────────────────────┐ ┌─────────────────────┐
│ KSP 1.12.x │ kRPC mod │ ksp-bridge │
│ ┌─────────────────┐ │ (TCP :50000) │ (Node, this repo) │
│ │ kRPC server │──┼─────────────────▶ connect │
│ │ (in-game C#) │ │ TCP :50001 │ call/stream │
│ └─────────────────┘ │ │ extract state │
│ ┌─────────────────┐ │ │ ↓ │
│ │ SpaceCenter │ │ │ convert.ts │
│ │ Vessel/Orbit/ │ │ │ ↓ │
│ │ CelestialBody │ │ │ POST /api/v1/ingest │
│ └─────────────────┘ │ │ every N seconds │
└───────────────────────┘ └──────────┬──────────┘
┌─────────────────────┐
│ kerbal-rt API │
│ (Phase 1a) │
│ Postgres+Redis │
└─────────────────────┘
```
---
## Running the bridge
### A. Without KSP (mock mode)
The bridge ships with a synthetic-state generator. Use it to verify the
HTTP pipeline end-to-end without needing KSP:
```bash
# Terminal 1: API
cd apps/api && PORT=4000 USE_IN_MEMORY=1 pnpm start
# Terminal 2: bridge (no kRPC_HOST, no KSP install)
cd apps/tools/ksp-bridge
KERBAL_RT_API_URL=http://localhost:4000 \
INGEST_API_KEY=test \
BRIDGE_POLL_MS=500 \
pnpm start
```
You'll see `[ksp-bridge] no kRPC server at 127.0.0.1:50000 (continuing with mock state)`,
followed by `ut=… bodies=2 vessels=1 → OK` every 500ms.
### B. With KSP (real kRPC)
#### 1. Install KSP + kRPC
```bash
# Install KSP 1.12.5 (Steam) or wherever you keep it
# Install CKAN
# https://github.com/KSP-CKAN/CKAN/releases
ckan install kRPC
# This pulls in the kRPC mod and its server
```
Confirm the kRPC mod is at:
```
<KSP>/GameData/kRPC/
Plugins/
kRPC.dll
ServiceDefinitions/
KRPC.proto
SpaceCenter.proto
...
```
#### 2. Start KSP, load your save, start the kRPC server
1. Launch KSP, load a save (your "no-warp" multiplayer save)
2. Right-click the kRPC icon in the toolbar → "Start server"
3. Defaults: port `50000` for RPC, port `50001` for stream
#### 3. Point the bridge at it
### A. Mock mode (no KSP needed)
```bash
cd apps/tools/ksp-bridge
KSP_KRPC_HOST=127.0.0.1 \
KSP_KRPC_PORT=50000 \
KSP_DIR=/path/to/Kerbal\ Space\ Program \
KERBAL_RT_API_URL=http://localhost:4000 \
INGEST_API_KEY=test \
BRIDGE_POLL_MS=1000 \
BRIDGE_POLL_MS=500 \
pnpm start
```
Set `KSP_DIR` to the path containing `GameData/kRPC/Plugins/ServiceDefinitions/`.
The bridge looks there for the .proto files. With that set, you'll see
`[ksp-bridge] found N .proto files in <path>`.
The bridge starts, tries to connect to `127.0.0.1:50000`, fails (no
kRPC server running), and falls back to MOCK mode: it emits synthetic
state every poll. This is great for verifying the HTTP pipeline and
the live-map / hub end-to-end without KSP.
#### 4. Verify
### B. With real KSP
- API `/api/v1/state` should return non-zero vessel/body counts
- `apps/live-map` (http://localhost:3001) shows real KSP vessels
- `apps/hub/debug` shows the same
- `[ksp-bridge]` log shows `ut=… → OK` every poll
1. Install KSP 1.12.5 (this is the version kRPC 0.5.x targets).
2. Install [CKAN](https://github.com/KSP-CKAN/CKAN).
3. From CKAN, install:
- `kRPC` (the mod itself, by [djungelorm](https://github.com/djungelorm))
- Any other mods you want
4. Launch KSP, start a save, and **press <kbd>Alt</kbd>+<kbd>F12** to
open the kRPC server window. Make sure the RPC server is on
`127.0.0.1:50000` and the Stream server is on `127.0.0.1:50001`.
5. Run the bridge:
---
## What kRPC calls does the bridge need?
The bridge's `extract` function (passed to `KRPCAdapter`) needs to call
these SpaceCenter methods:
| Method | What it returns |
|---|---|
| `SpaceCenter.ut()` | double — KSP universal time |
| `SpaceCenter.bodies` | list of CelestialBody |
| `SpaceCenter.vessels` | list of Vessel |
| `SpaceCenter.active_vessel` | Vessel (or null) |
| `CelestialBody.name` | string |
| `CelestialBody.parent` | CelestialBody (or null) |
| `CelestialBody.radius` | double (m) |
| `CelestialBody.sphere_of_influence` | double (m) |
| `CelestialBody.gravitational_parameter` | double (m³/s²) |
| `CelestialBody.rotation_period` | double (s) |
| `CelestialBody.axial_tilt` | double (rad) |
| `CelestialBody.orbit` | Orbit (Keplerian elements) |
| `Vessel.name` | string |
| `Vessel.type` | enum string (Probe, Ship, Station, Lander, Base, Rover, EVA) |
| `Vessel.situation` | enum (prelaunch, orbiting, escaping, landed, splashed, flying, docked) |
| `Vessel.orbit` | Orbit (Keplerian elements around the reference body) |
| `Orbit.semi_major_axis` | double (m) |
| `Orbit.eccentricity` | double |
| `Orbit.inclination` | double (rad) |
| `Orbit.longitude_of_ascending_node` | double (rad) |
| `Orbit.argument_of_periapsis` | double (rad) |
| `Orbit.mean_anomaly_at_epoch` | double (rad) |
| `Orbit.epoch` | double (s) |
| `Orbit.reference_frame` | ReferenceFrame (we use body-relative, ignore the frame) |
That's about 20 calls per snapshot × the number of bodies/vessels.
For a save with 20 vessels and 17 bodies, expect ~400 RPC calls per
poll. At 1Hz polling, kRPC can easily handle this (it batches).
---
## How the kRPC protocol works (for the next dev)
```
1. Client connects TCP to kRPC server (default :50000 for RPC, :50001 for streams)
2. Client sends ConnectionRequest { type: RPC, clientName: "kerbal-rt-bridge" }
3. Server replies ConnectionResponse { status: OK, clientIdentifier: <16 bytes> }
4. Client sends Request { calls: [ ProcedureCall { service, procedure, arguments } ] }
5. Server replies Response { results: [ ProcedureResult { value: <bytes> } ] }
6. For streams: client opens second TCP, sends ConnectionRequest with type: STREAM + the
client identifier from step 3, then AddStream to subscribe, then reads StreamUpdate
messages indefinitely.
Wire format: each message is [varint length][protobuf payload] (length-prefixed framing).
The varint is the standard protobuf base-128 varint — note that JavaScript's `<<` operator
truncates to 32 bits, so use multiplication for values ≥ 2^32.
```
The full implementation is in `packages/krpc-client/src/`:
- `connection.ts` — varint + length-prefix framing + per-socket read queue
- `schema.ts` — hand-written protobufjs schema for the kRPC meta-protocol
- `client.ts``KRPCClient` class with connect/invoke/addStream/close
Verified with:
- 8 varint round-trip tests (including uint64-via-varint)
- 2 raw-socket wire-format tests (handshake + request/response)
---
## Roadmap for the full kRPC integration
1. **Load .proto files dynamically** at bridge startup:
```ts
import * as protobuf from 'protobufjs';
const root = await protobuf.load(`${protoDir}/KRPC.proto`);
const root2 = await protobuf.load(`${protoDir}/SpaceCenter.proto`);
// merge into one root, then build typed service proxies
```bash
cd apps/tools/ksp-bridge
KERBAL_RT_API_URL=http://localhost:4000 \
KSP_KRPC_HOST=127.0.0.1 \
KSP_KRPC_PORT=50000 \
KSP_KRPC_STREAM_PORT=50001 \
BRIDGE_POLL_MS=1000 \
pnpm start
```
2. **Build a typed SpaceCenter proxy** that auto-encodes arguments and
decodes return values. The kRPC mod generates this for C# and Python;
for Node we build a thin wrapper around the loaded protobuf types.
3. **Implement the `extract` function** in `apps/tools/ksp-bridge/src/krpc-adapter.ts`:
- Call `SpaceCenter.ut()` for the current UT
- Iterate `SpaceCenter.bodies` and read each property
- Iterate `SpaceCenter.vessels` and read each property
- Build a `KRPCState` and return
4. **Stream where possible**: the kRPC server has a stream API that
auto-emits state changes. Switching to streams reduces RPC overhead.
5. **Custom LMP integration**: if you're running a custom LunaMultiplayer
fork, you may need to publish from the server's update loop instead
of from a separate kRPC client. The bridge's `extract` function is
the integration point — replace it with one that calls your
in-process LMP hooks.
---
The bridge will log `connected to kRPC at 127.0.0.1:50000 — running
with real KSP state` and start polling.
## License / Attribution
## Architecture
kRPC is BSD-licensed (https://github.com/krpc/krpc). The schema in
`packages/krpc-client/src/schema.ts` is adapted from
https://github.com/krpc/krpc/blob/main/protobuf/krpc.proto, which
is also BSD-licensed. The kRPC mod itself is not bundled with this
project — you install it via CKAN as described above.
### Two layers
1. **`@kerbal-rt/krpc-client`** — the low-level kRPC client.
- TCP connection (RPC port + stream port)
- Length-prefixed protobuf framing
- Connection handshake (`ConnectionRequest`/`ConnectionResponse`)
- Procedure invocation (`Request`/`Response`)
- Stream subscription (`AddStream`/`StreamUpdate`)
- Plus a **typed service client** built on top:
- Loads the service catalog via `KRPC.GetServices()` on connect
- Encodes procedure arguments based on the cached type info
- Decodes return values based on the cached type info
- Knows about the kRPC value encoding (primitives, classes,
enums, collections, system messages)
2. **`apps/tools/ksp-bridge`** — the actual KSP bridge.
- `krpc-adapter.ts` — owns the KRPCClient + KrpcServices, exposes
`connect()` / `readState()` / `disconnect()`
- `extract.ts` — calls SpaceCenter methods to read the full universe
state and produces a `UniverseSnapshot`
- `bridge.ts` — the polling loop, HTTP POST to API, retry / reconnect
- `index.ts` — entrypoint; falls back to MOCK mode if no kRPC server
### No .proto files needed
We do **not** need the kRPC mod's `.proto` files on disk. The kRPC
server provides the full service catalog (procedures, classes, enums,
exceptions) via `KRPC.GetServices()` on connect, and we cache that into
a `ServiceCache` for lookups. The value encoding is implemented in
`packages/krpc-client/src/decoder.ts`.
The original plan (Phase 1c) called for loading the `.proto` files
with `protobufjs.loadSync()`. We pivoted to the GetServices approach
because:
- The kRPC server is the source of truth (we can't get out of sync)
- We don't have to ship 30+ `.proto` files with the bridge
- The .proto files are mostly for static code generation in other
languages; for a dynamic client, GetServices is sufficient
## What we read from KSP
Per poll, the bridge makes ~280 procedure calls (for a stock KSP save
with 15 bodies and 5 vessels). At `BRIDGE_POLL_MS=1000` that's
comfortably within what kRPC can handle on loopback. If you need more
throughput, the obvious optimization is to batch the calls into a
single `KRPC.Request` with multiple `ProcedureCall` entries (the
server already supports this; we just don't use it yet).
### Top-level
| Procedure | Returns | Used for |
|---|---|---|
| `SpaceCenter.GetUT()` | `double` | Universal Time (game seconds since epoch) |
| `SpaceCenter.GetBodies()` | `list<CelestialBody>` | Object ids of all bodies |
| `SpaceCenter.GetVessels()` | `list<Vessel>` | Object ids of all vessels |
### Per CelestialBody (8 calls per body)
| Procedure | Returns | Field |
|---|---|---|
| `CelestialBody.GetName(self)` | `string` | `name` |
| `CelestialBody.GetParent(self)` | `CelestialBody` (nullable) | `parentId` |
| `CelestialBody.GetRadius(self)` | `double` | `radius` (m) |
| `CelestialBody.GetSphereOfInfluence(self)` | `double` | `sphereOfInfluence` (m) |
| `CelestialBody.GetGravitationalParameter(self)` | `double` | `μ` (m³/s²) |
| `CelestialBody.GetRotationPeriod(self)` | `double` | `rotationPeriod` (s) |
| `CelestialBody.GetAxialTilt(self)` | `double` | `axialTilt` (rad) |
| `CelestialBody.GetOrbit(self)` | `Orbit` | (then 8 orbit calls) |
### Per Orbit (8 calls per orbit)
| Procedure | Returns | Field |
|---|---|---|
| `Orbit.GetSemiMajorAxis(self)` | `double` | `semiMajorAxis` (m) |
| `Orbit.GetEccentricity(self)` | `double` | `eccentricity` |
| `Orbit.GetInclination(self)` | `double` | `inclination` (rad) |
| `Orbit.GetLongitudeOfAscendingNode(self)` | `double` | `longitudeOfAscendingNode` (rad) |
| `Orbit.GetArgumentOfPeriapsis(self)` | `double` | `argumentOfPeriapsis` (rad) |
| `Orbit.GetMeanAnomalyAtEpoch(self)` | `double` | `meanAnomalyAtEpoch` (rad) |
| `Orbit.GetEpoch(self)` | `double` | `epoch` (s) |
| `Orbit.GetReferenceBody(self)` | `CelestialBody` (nullable) | (for verification only) |
### Per Vessel (5 calls per vessel)
| Procedure | Returns | Field |
|---|---|---|
| `Vessel.GetName(self)` | `string` | `name` |
| `Vessel.GetType(self)` | `VesselType` (enum) | `type` (resolved to name) |
| `Vessel.GetSituation(self)` | `VesselSituation` (enum) | `situation` (raw int code) |
| `Vessel.GetOrbit(self)` | `Orbit` | (then 8 orbit calls) |
| `Vessel.GetReferenceBody(self)` | `CelestialBody` | `referenceBodyId` (resolved to name) |
## What's NOT in scope yet (deferred work)
- **Streams** — we don't subscribe to kRPC streams yet. We're
polling. For a real-time UI, switching to streams (or hybrid
poll+stream) would reduce latency and load. kRPC has `AddStream`
and the stream port is already wired in.
- **Batched calls** — every kRPC call is its own request. We could
batch multiple `ProcedureCall` entries in a single `Request` for
~10x throughput.
- **Ground stations** — kRPC doesn't expose ground stations natively.
The ksp-bridge accepts them as static config or via mod integration.
- **Comm nets / signal strength** — needs the `CommNet` API. kRPC
has it, but we don't use it yet.
- **Crew / science** — not in the ksp-bridge scope right now.
- **Maneuver nodes** — easy to add (`Vessel.GetManeuverNode()` etc.)
but not needed for the live map / mission clock.
## Troubleshooting
### `no kRPC server at 127.0.0.1:50000`
Either KSP isn't running, or the kRPC server isn't enabled. Open the
kRPC window in-game (<kbd>Alt</kbd>+<kbd>F12</kbd>) and make sure
"Start server" is checked.
### `procedure not found in service cache`
The kRPC server returned a procedure that we don't know about. This
usually means the kRPC version is older or newer than we expect
(we target 0.5.x). The ServiceCache will log the procedures it knows
about; cross-check with the in-game kRPC window.
### `wrong number of arguments`
The procedure signature in the cache doesn't match what we're sending.
This can happen if the kRPC version has a different parameter order
or count for a procedure we use. The fix is in
`apps/tools/ksp-bridge/src/extract.ts` — adjust the call site.