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:
+163
-212
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user