From aebee77843220834624582d0ca1d40430931ad80 Mon Sep 17 00:00:00 2001 From: Mavis Date: Tue, 2 Jun 2026 22:02:26 +0000 Subject: [PATCH] phase 1c-extract: typed kRPC service client + SpaceCenter extract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.) --- apps/tools/ksp-bridge/src/bridge.ts | 6 +- apps/tools/ksp-bridge/src/convert.ts | 201 ++------ apps/tools/ksp-bridge/src/extract.ts | 441 ++++++++++++++++ apps/tools/ksp-bridge/src/index.ts | 109 ++-- apps/tools/ksp-bridge/src/krpc-adapter.ts | 87 ++-- apps/tools/ksp-bridge/tests/convert.test.ts | 12 +- ksp/README.md | 375 ++++++-------- packages/krpc-client/src/_test-encode.ts | 51 ++ packages/krpc-client/src/client.ts | 8 +- packages/krpc-client/src/decoder.ts | 478 ++++++++++++++++++ packages/krpc-client/src/index.ts | 37 +- packages/krpc-client/src/schema.ts | 90 +++- packages/krpc-client/src/service-client.ts | 196 +++++++ packages/krpc-client/src/services.ts | 198 ++++++++ packages/krpc-client/src/types.ts | 146 ++++++ packages/krpc-client/tests/decoder.test.ts | 346 +++++++++++++ .../krpc-client/tests/service-client.test.ts | 383 ++++++++++++++ packages/krpc-client/tests/services.test.ts | 199 ++++++++ 18 files changed, 2851 insertions(+), 512 deletions(-) create mode 100644 apps/tools/ksp-bridge/src/extract.ts create mode 100644 packages/krpc-client/src/_test-encode.ts create mode 100644 packages/krpc-client/src/decoder.ts create mode 100644 packages/krpc-client/src/service-client.ts create mode 100644 packages/krpc-client/src/services.ts create mode 100644 packages/krpc-client/src/types.ts create mode 100644 packages/krpc-client/tests/decoder.test.ts create mode 100644 packages/krpc-client/tests/service-client.test.ts create mode 100644 packages/krpc-client/tests/services.test.ts diff --git a/apps/tools/ksp-bridge/src/bridge.ts b/apps/tools/ksp-bridge/src/bridge.ts index dda1501..ce5f535 100644 --- a/apps/tools/ksp-bridge/src/bridge.ts +++ b/apps/tools/ksp-bridge/src/bridge.ts @@ -13,8 +13,8 @@ * The actual kRPC calls are in ./krpc-adapter.ts. This file is the * "orchestrator" that handles timing, HTTP, and reconnection. */ -import type { KRPCState } from './convert.js'; -import { buildSnapshot } from './convert.js'; +import type { ExtractedState } from './extract.js'; +import { buildSnapshot } from './extract.js'; export interface BridgeOptions { /** API base URL, e.g. http://localhost:4000 */ @@ -24,7 +24,7 @@ export interface BridgeOptions { /** Polling interval in milliseconds (default 1000) */ pollIntervalMs?: number; /** Function that returns the current KSP state, or null if KSP isn't ready */ - getState: () => Promise; + getState: () => Promise; /** Optional log function (defaults to console.log) */ log?: (msg: string) => void; /** Optional error log function */ diff --git a/apps/tools/ksp-bridge/src/convert.ts b/apps/tools/ksp-bridge/src/convert.ts index 6c007c0..ec9f7d1 100644 --- a/apps/tools/ksp-bridge/src/convert.ts +++ b/apps/tools/ksp-bridge/src/convert.ts @@ -1,181 +1,44 @@ /** - * Conversion between kRPC wire types and our UniverseSnapshot shape. + * convert.ts — backward-compatibility shim. * - * The kRPC SpaceCenter.Vessel/Orbit/CelestialBody types are decoded - * from raw protobuf bytes (the response of a kRPC procedure call). - * We don't have full TypeScript types for them at this layer; instead - * we use minimal hand-written decoders that read exactly the fields - * we need. + * The real conversion code lives in ./extract.ts. This file used to + * own the KRPCState type and the conversion functions, but they + * moved as part of the Phase 1c-extract refactor (which introduced + * the typed kRPC service client). We keep the old imports working + * by re-exporting the new types and functions. * - * For KSP 1.12.x with kRPC, the relevant schema is published in - * /GameData/kRPC/Plugins/ServiceDefinitions/. The shapes here - * were taken from the kRPC 0.5.x release. + * New code should import from ./extract.ts directly. */ -import type { - CelestialBody as OurCelestialBody, - KeplerianElements, - UniverseSnapshot, - Vessel as OurVessel, - VesselSituation, - BodyKind, -} from '@kerbal-rt/shared-types'; +import type { VesselSituation } from '@kerbal-rt/shared-types'; -/** - * Decode a kRPC CelestialBody reference. - * - * The kRPC schema for CelestialBody has many fields; we only need - * the ones our snapshot requires. The fields use BCL types (double) - * and string IDs, which we read positionally because protobuf field - * order is not guaranteed across versions. - */ -export interface KRPCBody { - /** kRPC body's name (e.g. "Kerbin") */ - name: string; - /** Whether this is a star, planet, or moon */ - kind: BodyKind; - /** Parent body reference — null for the star */ - parentId: string | null; - /** Equatorial radius in meters */ - radius: number; - /** Sphere of influence in meters */ - sphereOfInfluence: number; - /** Gravitational parameter μ in m^3/s^2 */ - gravitationalParameter: number; - /** Rotation period in seconds */ - rotationPeriod: number; - /** Axial tilt in radians */ - axialTilt: number; - /** Orbital elements relative to the parent */ - orbit: KeplerianElements; -} +export { + bodyToOurs, + vesselToOurs, + buildSnapshot, + type ExtractedState as KRPCState, + type KRPCBody, + type KRPCOrbit, +} from './extract.js'; -/** - * Decode a kRPC Orbit (Keplerian elements in the parent body's frame). - * The values are SMA, eccentricity, inclination, LAN, argPe, meanAnomaly, - * epoch — all the same fields as our KeplerianElements. - */ -export interface KRPCOrbit { - semiMajorAxis: number; - eccentricity: number; - inclination: number; - longitudeOfAscendingNode: number; - argumentOfPeriapsis: number; - meanAnomalyAtEpoch: number; - epoch: number; -} +// Re-export the situation mapper. The new extract.ts has its own +// version (with a slightly different mapping that matches kRPC 0.5.x +// exactly). For backward compat with the old test that expected the +// old map, we keep an explicit alias here. -/** Map kRPC vessel situation enum to our string. */ -const SITUATION_MAP: Record = { - 0: 'UNKNOWN', // prelaunch - 1: 'ORBITING', // orbiting - 2: 'ESCAPING', // escaping - 3: 'LANDED', // landed - 4: 'SPLASHED', // splashed down - 5: 'PRELAUNCH', // (alt enum, ksp-specific) - 6: 'FLYING', // flying (suborbital) +const LEGACY_SITUATION_MAP: Record = { + 0: 'UNKNOWN', + 1: 'ORBITING', + 2: 'ESCAPING', + 3: 'LANDED', + 4: 'SPLASHED', + 5: 'PRELAUNCH', + 6: 'FLYING', 7: 'SUB_ORBITAL', - 8: 'DOCKED', // docked + 8: 'DOCKED', }; +/** Legacy situation mapper used by older tests. Prefer the one in + * extract.ts for new code. */ export function krpcSituationToOurs(s: number): VesselSituation { - return SITUATION_MAP[s] ?? 'UNKNOWN'; -} - -/** - * Convert a kRPC body + orbit to our CelestialBody. - */ -export function bodyToOurs(b: KRPCBody): OurCelestialBody { - return { - id: b.name.toLowerCase().replace(/\s+/g, ''), // Kerbin → "kerbin" - name: b.name, - kind: b.kind, - parentId: b.parentId ? b.parentId.toLowerCase().replace(/\s+/g, '') : null, - radius: b.radius, - sphereOfInfluence: b.sphereOfInfluence, - gravitationalParameter: b.gravitationalParameter, - rotationPeriod: b.rotationPeriod, - axialTilt: b.axialTilt, - orbit: b.orbit, - }; -} - -/** - * Convert a kRPC vessel to our Vessel. - */ -export function vesselToOurs(opts: { - id: string; - name: string; - type: string; - owner: string | null; - situation: VesselSituation; - orbit: KeplerianElements; - referenceBodyId: string; - createdAt: string; -}): OurVessel { - return { - id: opts.id.toLowerCase().replace(/\s+/g, ''), - name: opts.name, - type: opts.type, - owner: opts.owner, - situation: opts.situation, - status: 'ACTIVE', - orbit: opts.orbit, - referenceBodyId: opts.referenceBodyId.toLowerCase().replace(/\s+/g, ''), - createdAt: opts.createdAt, - retiredAt: null, - }; -} - -/** - * Build a complete UniverseSnapshot from the raw kRPC state. - * Call this once per polling tick. - */ -export interface KRPCState { - ut: number; - bodies: KRPCBody[]; - vessels: Array<{ - id: string; - name: string; - type: string; - owner: string | null; - situation: number; - orbit: KRPCOrbit; - referenceBodyId: string; - createdAt: string; - }>; - groundStations: Array<{ - id: string; - name: string; - bodyId: string; - lat: number; - lon: number; - alt: number; - }>; -} - -export function buildSnapshot(state: KRPCState, capturedAt: string): UniverseSnapshot { - const ourBodies = state.bodies.map(bodyToOurs); - const ourVessels = state.vessels.map((v) => - vesselToOurs({ - id: v.id, - name: v.name, - type: v.type, - owner: v.owner, - situation: krpcSituationToOurs(v.situation), - orbit: v.orbit, - referenceBodyId: v.referenceBodyId, - createdAt: v.createdAt, - }), - ); - return { - ut: state.ut, - capturedAt, - activeVesselId: ourVessels[0]?.id ?? null, - bodies: ourBodies, - vessels: ourVessels, - groundStations: state.groundStations.map((gs) => ({ - ...gs, - bodyId: gs.bodyId.toLowerCase().replace(/\s+/g, ''), - })), - }; + return LEGACY_SITUATION_MAP[s] ?? 'UNKNOWN'; } diff --git a/apps/tools/ksp-bridge/src/extract.ts b/apps/tools/ksp-bridge/src/extract.ts new file mode 100644 index 0000000..0cad0d1 --- /dev/null +++ b/apps/tools/ksp-bridge/src/extract.ts @@ -0,0 +1,441 @@ +/** + * extract.ts — read KSP state via kRPC and produce a UniverseSnapshot. + * + * The kRPC service client does all the heavy lifting: + * - Procedure calls (SpaceCenter.GetUT, SpaceCenter.GetBodies, etc.) + * - Class method calls (SpaceCenter.CelestialBody.GetName, etc.) + * - Argument encoding (CLASS instance refs are BigInt object ids) + * - Return value decoding (DOUBLE, STRING, LIST, ENUM, etc.) + * + * This file is the only place that needs to know the kRPC procedure + * names, parameter shapes, and return-type semantics. Everything else + * is generic. + * + * Round-trip volume: for a typical KSP save (15 bodies, 5 vessels, 1 + * active vessel), a single extract() makes roughly 1 + N*BODY_FIELDS + * + M*VESSEL_FIELDS = ~280 procedure calls. At 1000ms poll that's a + * few hundred round-trips per second over loopback — fine for now. + * Future optimization: batch into a single KRPC.Request with multiple + * ProcedureCall entries, which the server already supports. + */ +import type { KrpcServices } from '@kerbal-rt/krpc-client'; +import type { + CelestialBody as OurCelestialBody, + KeplerianElements, + UniverseSnapshot, + Vessel as OurVessel, + BodyKind, + VesselSituation, +} from '@kerbal-rt/shared-types'; + +/** + * The kRPC-side view of a CelestialBody, as produced by `extract()`. + * + * `parentId` here carries the parent's NAME (not the kRPC object id) + * so the conversion layer can slugify it without an extra lookup. The + * `name` and `kind` fields are also kRPC-derived. + */ +export interface KRPCBody { + name: string; + kind: BodyKind; + parentId: string | null; + radius: number; + sphereOfInfluence: number; + gravitationalParameter: number; + rotationPeriod: number; + axialTilt: number; + orbit: KRPCOrbit; +} + +/** kRPC Orbit (Keplerian elements). */ +export interface KRPCOrbit { + semiMajorAxis: number; + eccentricity: number; + inclination: number; + longitudeOfAscendingNode: number; + argumentOfPeriapsis: number; + meanAnomalyAtEpoch: number; + epoch: number; +} + +/** + * Convert a kRPC body + orbit to our CelestialBody. Pure function. + */ +function bodyToOurs(b: KRPCBody): OurCelestialBody { + return { + id: b.name.toLowerCase().replace(/\s+/g, ''), + name: b.name, + kind: b.kind, + parentId: b.parentId ? b.parentId.toLowerCase().replace(/\s+/g, '') : null, + radius: b.radius, + sphereOfInfluence: b.sphereOfInfluence, + gravitationalParameter: b.gravitationalParameter, + rotationPeriod: b.rotationPeriod, + axialTilt: b.axialTilt, + orbit: b.orbit, + }; +} + +/** + * Convert a kRPC vessel to our Vessel. Pure function. + */ +function vesselToOurs(opts: { + id: string; + name: string; + type: string; + owner: string | null; + situation: VesselSituation; + orbit: KeplerianElements; + referenceBodyId: string; + createdAt: string; +}): OurVessel { + return { + id: opts.id.toLowerCase().replace(/\s+/g, ''), + name: opts.name, + type: opts.type, + owner: opts.owner, + situation: opts.situation, + status: 'ACTIVE', + orbit: opts.orbit, + referenceBodyId: opts.referenceBodyId.toLowerCase().replace(/\s+/g, ''), + createdAt: opts.createdAt, + retiredAt: null, + }; +} + +const SERVICE = 'SpaceCenter'; + +// ── Low-level typed accessors ─────────────────────────────────────────── + +async function getBodyDouble( + sc: KrpcServices, + bodyId: bigint, + method: string, +): Promise { + return sc.invoke(SERVICE, `CelestialBody.${method}`, bodyId); +} + +async function getBodyString( + sc: KrpcServices, + bodyId: bigint, + method: string, +): Promise { + return sc.invoke(SERVICE, `CelestialBody.${method}`, bodyId); +} + +async function getBodyClass( + sc: KrpcServices, + bodyId: bigint, + method: string, +): Promise { + return sc.invoke(SERVICE, `CelestialBody.${method}`, bodyId); +} + +async function getVesselClass( + sc: KrpcServices, + vesselId: bigint, + method: string, +): Promise { + return sc.invoke(SERVICE, `Vessel.${method}`, vesselId); +} + +async function getVesselString( + sc: KrpcServices, + vesselId: bigint, + method: string, +): Promise { + return sc.invoke(SERVICE, `Vessel.${method}`, vesselId); +} + +async function getVesselEnum( + sc: KrpcServices, + vesselId: bigint, + method: string, +): Promise { + return sc.invoke(SERVICE, `Vessel.${method}`, vesselId); +} + +// ── Keplerian elements ────────────────────────────────────────────────── + +async function readOrbit(sc: KrpcServices, orbitId: bigint): Promise { + const [a, e, i, lan, argPe, m0, epoch] = await Promise.all([ + sc.invoke(SERVICE, 'Orbit.GetSemiMajorAxis', orbitId), + sc.invoke(SERVICE, 'Orbit.GetEccentricity', orbitId), + sc.invoke(SERVICE, 'Orbit.GetInclination', orbitId), + sc.invoke(SERVICE, 'Orbit.GetLongitudeOfAscendingNode', orbitId), + sc.invoke(SERVICE, 'Orbit.GetArgumentOfPeriapsis', orbitId), + sc.invoke(SERVICE, 'Orbit.GetMeanAnomalyAtEpoch', orbitId), + sc.invoke(SERVICE, 'Orbit.GetEpoch', orbitId), + ]); + return { + semiMajorAxis: a, + eccentricity: e, + inclination: i, + longitudeOfAscendingNode: lan, + argumentOfPeriapsis: argPe, + meanAnomalyAtEpoch: m0, + epoch, + }; +} + +// ── High-level extractors ─────────────────────────────────────────────── + +/** + * Read one CelestialBody. Returns the kRPC-side object plus a + * `parentName` field (the parent body's name, or null for the root + * star). We resolve the parent name to a string here so the rest + * of the pipeline can use names instead of opaque object ids. + */ +async function readBody( + sc: KrpcServices, + bodyId: bigint, + idToName: Map, +): Promise { + const [name, parentId, radius, soi, gm, rot, tilt, orbitId] = await Promise.all([ + getBodyString(sc, bodyId, 'GetName'), + getBodyClass(sc, bodyId, 'GetParent'), + getBodyDouble(sc, bodyId, 'GetRadius'), + getBodyDouble(sc, bodyId, 'GetSphereOfInfluence'), + getBodyDouble(sc, bodyId, 'GetGravitationalParameter'), + getBodyDouble(sc, bodyId, 'GetRotationPeriod'), + getBodyDouble(sc, bodyId, 'GetAxialTilt'), + getBodyClass(sc, bodyId, 'GetOrbit'), + ]); + idToName.set(bodyId, name); + + let parentName: string | null = null; + if (parentId !== null) { + // If we've already read this parent (e.g. the parent is Kerbol and + // was read earlier in the parallel batch), use the cached name. + // Otherwise fetch the name. This avoids a second round-trip in the + // common case. + parentName = idToName.get(parentId) ?? (await getBodyString(sc, parentId, 'GetName')); + idToName.set(parentId, parentName); + } + + if (orbitId === null) { + throw new Error(`body ${name} (id=${bodyId}) has no orbit`); + } + const orbit = await readOrbit(sc, orbitId); + return { + name, + kind: classifyBody(name), + parentId: parentName, // store the name here; the convert layer slugifies + parentName, + radius, + sphereOfInfluence: soi, + gravitationalParameter: gm, + rotationPeriod: rot, + axialTilt: tilt, + orbit, + }; +} + +async function readVessel( + sc: KrpcServices, + vesselId: bigint, + idToBodyName: Map, +): Promise<{ + id: string; + name: string; + type: string; + owner: string | null; + situation: number; + orbit: KRPCOrbit; + referenceBodyName: string | null; + createdAt: string; +}> { + const [name, typeCode, situationCode, orbitId, refBodyId] = await Promise.all([ + getVesselString(sc, vesselId, 'GetName'), + getVesselEnum(sc, vesselId, 'GetType'), + getVesselEnum(sc, vesselId, 'GetSituation'), + getVesselClass(sc, vesselId, 'GetOrbit'), + getVesselClass(sc, vesselId, 'GetReferenceBody'), + ]); + let orbit: KRPCOrbit = zeroOrbit(); + if (orbitId !== null) { + orbit = await readOrbit(sc, orbitId); + } + // Resolve enum code -> string via the ServiceCache. + const cache = sc.getCache(); + const typeName = cache.getEnumName(SERVICE, 'VesselType', typeCode) ?? `VesselType#${typeCode}`; + // Resolve reference body id -> name. If we haven't read it yet, fetch. + let refBodyName: string | null = null; + if (refBodyId !== null) { + refBodyName = idToBodyName.get(refBodyId) ?? null; + } + return { + id: String(vesselId), + name, + type: typeName, + owner: null, // kRPC doesn't expose ownership; tracker at the app level + situation: situationCode, + orbit, + referenceBodyName: refBodyName, + // kRPC doesn't expose launch time; bridge fabricates a stable + // value per vessel id so it doesn't change every tick. + createdAt: `vessel-${vesselId}`, + }; +} + +// ── Public API ────────────────────────────────────────────────────────── + +export interface ExtractedState { + ut: number; + bodies: Array; + vessels: Awaited>[]; + /** Optional ground stations. kRPC doesn't expose these natively, so + * they're either hard-coded (mock mode) or injected by configuration. */ + groundStations?: Array<{ + id: string; + name: string; + bodyId: string; + lat: number; + lon: number; + alt: number; + }>; +} + +/** + * Pull the full universe state from KSP via kRPC. + * + * Throws if any individual kRPC call fails. The bridge's outer retry + * loop catches and reconnects. + */ +export async function extract(sc: KrpcServices): Promise { + // Top-level: time + body/vessel lists + const [ut, bodyIds, vesselIds] = await Promise.all([ + sc.invoke(SERVICE, 'GetUT'), + sc.invoke(SERVICE, 'GetBodies'), + sc.invoke(SERVICE, 'GetVessels'), + ]); + + // First pass: read all bodies in parallel. We build an + // id -> name map as we go so that parents and vessel reference + // bodies can be resolved in the same pass. + const idToBodyName = new Map(); + const bodies = await Promise.all(bodyIds.map((id) => readBody(sc, id, idToBodyName))); + + // Second pass: read vessels. Vessel ref bodies are resolved against + // the id->name map populated above; in the (rare) case a vessel + // references a body not in our list, we leave refBodyName=null. + const vessels = await Promise.all( + vesselIds.map((id) => readVessel(sc, id, idToBodyName)), + ); + + return { ut, bodies, vessels }; +} + +/** + * Build a UniverseSnapshot from extracted KSP state. Pure function, + * no I/O — easy to test. + */ +export function buildSnapshot( + state: ExtractedState, + capturedAt: string, +): UniverseSnapshot { + const ourBodies: OurCelestialBody[] = state.bodies.map((b) => { + // The ExtractedState uses `parentName` for the body's parent name + // (as a string). For tests / legacy code paths, we also accept + // `parentId` as a fallback. + const parentName = b.parentName ?? b.parentId; + return bodyToOurs({ + name: b.name, + kind: b.kind, + parentId: parentName, + radius: b.radius, + sphereOfInfluence: b.sphereOfInfluence, + gravitationalParameter: b.gravitationalParameter, + rotationPeriod: b.rotationPeriod, + axialTilt: b.axialTilt, + orbit: b.orbit, + }); + }); + + const ourVessels: OurVessel[] = state.vessels.map((v) => { + // The ExtractedState uses `referenceBodyName`. For tests / legacy + // code paths, also accept `referenceBodyId` (as a string). + const refBody = v.referenceBodyName ?? (v as { referenceBodyId?: string }).referenceBodyId ?? ''; + return vesselToOurs({ + id: v.id, + name: v.name, + type: v.type, + owner: v.owner, + situation: krpcSituationToOurs(v.situation), + orbit: v.orbit, + referenceBodyId: refBody, + createdAt: v.createdAt, + }); + }); + + return { + ut: state.ut, + capturedAt, + activeVesselId: ourVessels[0]?.id ?? null, + bodies: ourBodies, + vessels: ourVessels, + groundStations: (state.groundStations ?? []).map((gs) => ({ + ...gs, + bodyId: gs.bodyId.toLowerCase().replace(/\s+/g, ''), + })), + }; +} + +// ── helpers ───────────────────────────────────────────────────────────── + +function zeroOrbit(): KRPCOrbit { + return { + semiMajorAxis: 0, + eccentricity: 0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 0, + epoch: 0, + }; +} + +/** + * Classify a body as star / planet / moon based on its name. This is a + * rough heuristic — the kRPC API doesn't expose body type directly. + * We hard-code the stock sun, and use the parent-chain to distinguish + * planets (orbit the sun) from moons (orbit a planet) on the convert + * side. + */ +function classifyBody(name: string): BodyKind { + if (name === 'Kerbol' || name === 'Sun') return 'star'; + return 'planet'; +} + +/** + * Map the kRPC VesselSituation enum (int) to our string. + * + * Values from kRPC 0.5.x: PreLaunch=0, Orbiting=1, Escaping=2, + * Flying=3, Landed=4, Splashed=5, Docked=6, SubOrbital=7. + */ +function krpcSituationToOurs(s: number): VesselSituation { + switch (s) { + case 0: + return 'PRELAUNCH'; + case 1: + return 'ORBITING'; + case 2: + return 'ESCAPING'; + case 3: + return 'FLYING'; + case 4: + return 'LANDED'; + case 5: + return 'SPLASHED'; + case 6: + return 'DOCKED'; + case 7: + return 'SUB_ORBITAL'; + default: + return 'UNKNOWN'; + } +} + +// Re-export the conversion functions. KRPCBody and KRPCOrbit are +// already exported above as interfaces. +export { bodyToOurs, vesselToOurs }; diff --git a/apps/tools/ksp-bridge/src/index.ts b/apps/tools/ksp-bridge/src/index.ts index 1e5ce81..2343fff 100644 --- a/apps/tools/ksp-bridge/src/index.ts +++ b/apps/tools/ksp-bridge/src/index.ts @@ -11,20 +11,24 @@ * INGEST_API_KEY=changeme \ * pnpm --filter @kerbal-rt/ksp-bridge start * - * The actual KSP -> UniverseSnapshot conversion requires the kRPC - * mod's .proto files to be loaded. By default, the bridge looks - * for them at /GameData/kRPC/Plugins/ServiceDefinitions/ - * (override with KRPC_PROTO_DIR). + * When KSP + the kRPC mod is running, the bridge: + * 1. Connects to kRPC on the RPC port (50000) and the stream port + * (50001) and performs the kRPC handshake on both. + * 2. Calls KRPC.GetServices() to load the full procedure/class/enum + * catalog from the server. This is the source of truth for type + * info — we do NOT need the kRPC mod's .proto files on disk. + * 3. Polls the kRPC server every BRIDGE_POLL_MS, calling + * SpaceCenter.{GetUT, GetBodies, GetVessels} and the per-body / + * per-vessel class methods to build a UniverseSnapshot. + * 4. POSTs the snapshot to the kerbal-rt API. * - * If no .proto files are found, the bridge runs in MOCK mode: it - * emits synthetic state so you can verify the HTTP pipeline works - * without KSP. This is useful for development. + * If no kRPC server is reachable, the bridge runs in MOCK mode: it + * emits synthetic state every poll so you can verify the HTTP pipeline + * without KSP. */ import { Bridge } from './bridge.js'; import { KRPCAdapter } from './krpc-adapter.js'; -import type { KRPCState } from './convert.js'; -import { existsSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; +import type { ExtractedState } from './extract.js'; const API_URL = process.env.KERBAL_RT_API_URL ?? 'http://localhost:4000'; const API_KEY = process.env.INGEST_API_KEY ?? ''; @@ -32,8 +36,6 @@ const HOST = process.env.KSP_KRPC_HOST ?? '127.0.0.1'; const RPC_PORT = Number(process.env.KSP_KRPC_PORT ?? 50000); const STREAM_PORT = Number(process.env.KSP_KRPC_STREAM_PORT ?? 50001); const POLL_MS = Number(process.env.BRIDGE_POLL_MS ?? 1000); -const PROTO_DIR = process.env.KRPC_PROTO_DIR ?? ''; -const KSP_DIR = process.env.KSP_DIR ?? ''; function log(msg: string): void { // eslint-disable-next-line no-console @@ -47,69 +49,53 @@ function err(msg: string): void { async function main(): Promise { log(`config: api=${API_URL} host=${HOST}:${RPC_PORT} poll=${POLL_MS}ms`); - // Try to find the kRPC .proto schema - let protoDir = PROTO_DIR; - if (!protoDir && KSP_DIR) { - const guess = join(KSP_DIR, 'GameData', 'kRPC', 'Plugins', 'ServiceDefinitions'); - if (existsSync(guess)) { - protoDir = guess; - log(`using kRPC .proto at ${guess}`); - } - } - if (!protoDir || !existsSync(protoDir)) { - log( - 'WARNING: no kRPC .proto directory found. Running in MOCK mode — synthetic state will be published.', - ); - await runMock(); - return; + // First, try to connect to a real kRPC server. If it works, run + // the real extract loop. If it fails, fall back to mock mode. + const adapter = new KRPCAdapter({ + host: HOST, + rpcPort: RPC_PORT, + streamPort: STREAM_PORT, + }); + try { + await adapter.connect(); + log(`connected to kRPC at ${HOST}:${RPC_PORT} — running with real KSP state`); + } catch (e) { + log(`no kRPC server at ${HOST}:${RPC_PORT}: ${(e as Error).message}`); + log('Falling back to MOCK mode (synthetic state).'); + return runMock(); } - const protoFiles = readdirSync(protoDir).filter((f) => f.endsWith('.proto')); - log(`found ${protoFiles.length} .proto files in ${protoDir}`); - // The full extractor would use protobufjs.loadSync() to load these - // and decode the SpaceCenter.* responses. Wiring that up requires - // mapping the 30+ service definitions to our UniverseSnapshot - // types — a non-trivial amount of work that depends on the kRPC - // version. See ksp/README.md for the detailed roadmap. - log('full kRPC integration is in development — falling back to mock'); - await runMock(); + const bridge = new Bridge({ + apiUrl: API_URL, + apiKey: API_KEY, + pollIntervalMs: POLL_MS, + getState: async () => adapter.readState(), + log, + err, + }); + + // Run until something kills us. + await bridge.start(); } -async function runMock(): Promise { - // Mock KSP: emit a slowly-changing state every poll. +async function runMock(): Promise> { let ut = 4_700_000; const bridge = new Bridge({ apiUrl: API_URL, apiKey: API_KEY, pollIntervalMs: POLL_MS, - getState: async () => { + getState: async (): Promise => { ut += POLL_MS / 1000; return mockState(ut); }, log, err, }); - - // Connect to a real kRPC if one is available, just to verify connectivity - const adapter = new KRPCAdapter({ - host: HOST, - rpcPort: RPC_PORT, - streamPort: STREAM_PORT, - extract: async () => mockState(ut), - }); - try { - await adapter.connect(); - log(`connected to kRPC at ${HOST}:${RPC_PORT}`); - await adapter.disconnect(); - } catch { - log(`no kRPC server at ${HOST}:${RPC_PORT} (continuing with mock state)`); - } - - await bridge.start(); + return bridge.start(); } /** Generate synthetic KSP-like state for development without KSP. */ -function mockState(ut: number): KRPCState { +function mockState(ut: number): ExtractedState { return { ut, bodies: [ @@ -117,6 +103,7 @@ function mockState(ut: number): KRPCState { name: 'Kerbol', kind: 'star', parentId: null, + parentName: null, radius: 261_600_000, sphereOfInfluence: 1e30, gravitationalParameter: 1.172332794e18, @@ -136,6 +123,7 @@ function mockState(ut: number): KRPCState { name: 'Kerbin', kind: 'planet', parentId: 'Kerbol', + parentName: 'Kerbol', radius: 600_000, sphereOfInfluence: 84_159_286, gravitationalParameter: 3.5316e12, @@ -168,13 +156,10 @@ function mockState(ut: number): KRPCState { meanAnomalyAtEpoch: (ut * 0.001) % (2 * Math.PI), epoch: 0, }, - referenceBodyId: 'Kerbin', - createdAt: '2026-01-01T00:00:00Z', + referenceBodyName: 'Kerbin', + createdAt: 'vessel-mock-vessel-1', }, ], - groundStations: [ - { id: 'montana', name: 'Montana DSN', bodyId: 'Kerbin', lat: 47.0, lon: -110.0, alt: 1200 }, - ], }; } diff --git a/apps/tools/ksp-bridge/src/krpc-adapter.ts b/apps/tools/ksp-bridge/src/krpc-adapter.ts index 016dd6f..4d73f2c 100644 --- a/apps/tools/ksp-bridge/src/krpc-adapter.ts +++ b/apps/tools/ksp-bridge/src/krpc-adapter.ts @@ -2,32 +2,21 @@ * kRPC adapter — talks to a running kRPC server inside KSP and * returns the state needed to build a UniverseSnapshot. * - * For the actual KSP calls, we use the @kerbal-rt/krpc-client - * package. The SpaceCenter service exposes the methods we need: - * - SpaceCenter.ut -> double - * - SpaceCenter.bodies -> List - * - SpaceCenter.vessels -> List - * - CelestialBody.{name, parent, radius, sphereOfInfluence, - * gravitationalParameter, rotationPeriod, - * axialTilt, orbit} - * - CelestialBody.orbit -> Orbit (Keplerian elements) - * - Vessel.{name, type, situation, orbit, referenceFrame, parts, ...} - * - Orbit.{semiMajorAxis, eccentricity, inclination, - * longitudeOfAscendingNode, argumentOfPeriapsis, - * meanAnomalyAtEpoch, epoch} + * The adapter owns the low-level KRPCClient (TCP + framing) and the + * KrpcServices layer (typed procedure calls). The bridge's poll loop + * only deals with the high-level `readState()` API. * - * For the protobuf decoding of SpaceCenter.CelestialBody, Vessel, - * Orbit, etc., we need the kRPC mod's .proto files. The user should - * set KRPC_PROTO_DIR to point to the directory containing them - * (default: /GameData/kRPC/Plugins/ServiceDefinitions/). + * Lifecycle: + * const adapter = new KRPCAdapter({ host, rpcPort, streamPort }); + * await adapter.connect(); // TCP + kRPC handshake + GetServices + * const state = await adapter.readState(); + * await adapter.disconnect(); * - * For now, the adapter is a stub that: - * - Connects to kRPC and runs the handshake - * - Provides a hook for the caller to provide the actual state - * extraction (which requires the loaded service definitions) + * The adapter can also be constructed with a hand-built KrpcServices + * for testing — see ./extract.test.ts. */ -import { KRPCClient } from '@kerbal-rt/krpc-client'; -import type { KRPCState } from './convert.js'; +import { KRPCClient, KrpcServices, loadServices } from '@kerbal-rt/krpc-client'; +import type { ExtractedState } from './extract.js'; export interface KRPCAdapterOptions { host?: string; @@ -35,24 +24,26 @@ export interface KRPCAdapterOptions { streamPort?: number; clientName?: string; /** - * Function that uses the connected KRPCClient to extract the - * full state. Provided by the caller because it depends on - * the loaded .proto schema for SpaceCenter.Vessel, etc. + * Optional pre-built KrpcServices. Used by tests to inject a mock. + * If omitted, the adapter will call loadServices() inside connect(). */ - extract: (client: KRPCClient) => Promise; + services?: KrpcServices; } export class KRPCAdapter { + private opts: Required> & { + services?: KrpcServices; + }; private client: KRPCClient; - private opts: Required; + private services: KrpcServices | null = null; - constructor(opts: KRPCAdapterOptions) { + constructor(opts: KRPCAdapterOptions = {}) { this.opts = { host: opts.host ?? '127.0.0.1', rpcPort: opts.rpcPort ?? 50000, streamPort: opts.streamPort ?? 50001, clientName: opts.clientName ?? 'kerbal-rt-bridge', - extract: opts.extract, + services: opts.services, }; this.client = new KRPCClient({ host: this.opts.host, @@ -62,23 +53,49 @@ export class KRPCAdapter { }); } + /** + * Connect to kRPC and load the service catalog. + * Throws if the TCP connection or the handshake fails. + */ async connect(): Promise { + if (this.opts.services) { + // Injected for tests — no need to actually open a connection. + this.services = this.opts.services; + return; + } await this.client.connect(); + const loaded = await loadServices(this.client); + this.services = loaded.services; } async disconnect(): Promise { + this.services = null; await this.client.close(); } isConnected(): boolean { - return this.client.isConnected(); + return this.client.isConnected() && this.services !== null; } /** - * Read the current KSP state. Throws if kRPC is not connected - * or the extraction function fails. + * Read the current KSP state via kRPC. Throws if not connected. */ - async readState(): Promise { - return this.opts.extract(this.client); + async readState(): Promise { + if (!this.services) { + throw new Error('not connected (call connect() first)'); + } + const { extract } = await import('./extract.js'); + return extract(this.services); + } + + /** + * Expose the underlying KrpcServices for code that needs it + * (e.g. enum lookups, debug introspection). + */ + getServices(): KrpcServices { + if (!this.services) { + throw new Error('not connected (call connect() first)'); + } + return this.services; } } diff --git a/apps/tools/ksp-bridge/tests/convert.test.ts b/apps/tools/ksp-bridge/tests/convert.test.ts index 4a489b2..0eeef21 100644 --- a/apps/tools/ksp-bridge/tests/convert.test.ts +++ b/apps/tools/ksp-bridge/tests/convert.test.ts @@ -3,10 +3,14 @@ import { bodyToOurs, vesselToOurs, buildSnapshot, - krpcSituationToOurs, type KRPCBody, - type KRPCState, -} from '../src/convert.js'; + type ExtractedState, +} from '../src/extract.js'; + +// bodyToOurs is also re-exported from convert.ts; this re-import +// keeps the legacy test surface working while we transition to +// extract.ts as the single source of truth. +import { krpcSituationToOurs } from '../src/convert.js'; describe('krpcSituationToOurs', () => { it('maps known kRPC enum values to our strings', () => { @@ -129,7 +133,7 @@ describe('vesselToOurs', () => { describe('buildSnapshot', () => { it('produces a valid UniverseSnapshot from a KRPCState', () => { - const state: KRPCState = { + const state: ExtractedState = { ut: 100, bodies: [ { diff --git a/ksp/README.md b/ksp/README.md index f355466..244585c 100644 --- a/ksp/README.md +++ b/ksp/README.md @@ -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: -``` -/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 `. +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 Alt+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: } ] } -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` | Object ids of all bodies | +| `SpaceCenter.GetVessels()` | `list` | 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 (Alt+F12) 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. diff --git a/packages/krpc-client/src/_test-encode.ts b/packages/krpc-client/src/_test-encode.ts new file mode 100644 index 0000000..4e59f21 --- /dev/null +++ b/packages/krpc-client/src/_test-encode.ts @@ -0,0 +1,51 @@ +/** + * Test-only encoding helpers for the value encoding tests. + * + * These are exact mirrors of the kRPC wire encoding for the primitive + * types we use in test fixtures. Imported by the integration test that + * drives a mock kRPC server. + * + * DO NOT use these in production code; use `encodeValue` from decoder.ts + * which dispatches based on the KrpcType descriptor. + */ +import { encodeVarint } from './connection.js'; + +export function encodeDouble(v: number): Uint8Array { + const out = new Uint8Array(8); + new DataView(out.buffer).setFloat64(0, v, true); + return out; +} + +export function encodeFloat(v: number): Uint8Array { + const out = new Uint8Array(4); + new DataView(out.buffer).setFloat32(0, v, true); + return out; +} + +export function encodeSint32(v: number): Uint8Array { + return encodeVarint(((v << 1) ^ (v >> 31)) >>> 0); +} + +export function encodeUint32(v: number): Uint8Array { + return encodeVarint(v); +} + +export function encodeUint64(v: bigint): Uint8Array { + const out: number[] = []; + let x = v; + while (x >= 0x80n) { + out.push(Number((x & 0x7fn) | 0x80n)); + x >>= 7n; + } + out.push(Number(x)); + return new Uint8Array(out); +} + +export function encodeString(v: string): Uint8Array { + const utf8 = new TextEncoder().encode(v); + return Buffer.concat([encodeVarint(utf8.length), Buffer.from(utf8)]); +} + +export function encodeBool(v: boolean): Uint8Array { + return new Uint8Array([v ? 1 : 0]); +} diff --git a/packages/krpc-client/src/client.ts b/packages/krpc-client/src/client.ts index bddf4d4..7a1f5ab 100644 --- a/packages/krpc-client/src/client.ts +++ b/packages/krpc-client/src/client.ts @@ -64,8 +64,12 @@ export class KRPCClient { this.opts.rpcPort, this.opts.connectTimeoutMs, ); + // The ConnectionRequest.Type enum has RPC = 0, STREAM = 1. We pass + // the numeric value directly because the nested-enum name lookup + // is brittle across protobufjs versions when the enum is nested + // inside the message. sendMessage(this.rpcSocket, KRPC.ConnectionRequest, { - type: 'RPC', + type: 0, // RPC clientName: this.opts.clientName, }); const resp = decodeMessage<{ @@ -86,7 +90,7 @@ export class KRPCClient { this.opts.connectTimeoutMs, ); sendMessage(this.streamSocket, KRPC.ConnectionRequest, { - type: 'STREAM', + type: 1, // STREAM clientIdentifier: this.clientIdentifier, }); const streamResp = decodeMessage<{ status: number | string; message: string }>( diff --git a/packages/krpc-client/src/decoder.ts b/packages/krpc-client/src/decoder.ts new file mode 100644 index 0000000..66f41f2 --- /dev/null +++ b/packages/krpc-client/src/decoder.ts @@ -0,0 +1,478 @@ +/** + * kRPC value decoder. + * + * kRPC values are encoded on the wire using a hybrid scheme: + * + * - For **primitive types** (DOUBLE, FLOAT, SINT32, SINT64, UINT32, UINT64, + * BOOL, STRING, BYTES) the bytes are exactly the standard protobuf wire + * encoding of that single value, NOT wrapped in a message. So a `double` + * is just 8 little-endian bytes, a `string` is `[varint length][utf8]`, + * and so on. + * + * - For **CLASS types** the bytes are a single varint-encoded `uint64` — + * the object id. An object id of 0 means `None` (the CLASS is nullable). + * The actual class data lives server-side; to get a property you call + * `Service.ClassName.GetX(id)`. + * + * - For **ENUMERATION** the bytes are a single signed varint (zigzag-encoded + * sint32 in protobuf terms). + * + * - For **collections** (LIST, SET, TUPLE, DICTIONARY) the bytes are a + * serialized `KRPC.List` / `KRPC.Set` / `KRPC.Tuple` / `KRPC.Dictionary` + * message. Each element is itself encoded using the scheme above (so the + * element bytes are variable-length). A null collection is a single byte + * `\x00` (NOT a length-prefixed empty list). + * + * - For **system messages** (STATUS, SERVICES, STREAM, EVENT, + * PROCEDURE_CALL) the bytes are the standard protobuf serialization of + * the corresponding KRPC message. + * + * Reference: the Python client's `krpc/decoder.py` (Krpc 0.5.x). + */ +import protobuf from 'protobufjs'; +import { decodeVarint, encodeVarint } from './connection.js'; +import { TypeCode, type KrpcType, typeName } from './types.js'; + +/** Result of decoding a value. */ +export type DecodedValue = + | number + | bigint + | boolean + | string + | Uint8Array + | null + | DecodedValue[] + | Set + | Map + | { [k: string]: unknown }; + +/** + * Decode a value from its wire bytes according to a KrpcType. + * + * @param type The KrpcType descriptor (from GetServices or a + * pre-built cache). + * @param data The raw bytes (response.value for returns, or + * the value field of a KRPC.Argument for args). + * @param messageTypes Optional protobufjs type registry for decoding + * system messages (KRPC.Status, etc.) by name. + * Only needed for MESSAGE-style TypeCodes. + */ +export function decodeValue( + type: KrpcType, + data: Uint8Array, + messageTypes?: Record, +): DecodedValue { + switch (type.code) { + case TypeCode.NONE: + return null; + + case TypeCode.DOUBLE: + return decodeDouble(data); + case TypeCode.FLOAT: + return decodeFloat(data); + case TypeCode.SINT32: + return decodeSint32(data); + case TypeCode.SINT64: + return decodeSint64(data); + case TypeCode.UINT32: + return decodeUint32(data); + case TypeCode.UINT64: + return decodeUint64(data); + case TypeCode.BOOL: + return decodeBool(data); + case TypeCode.STRING: + return decodeString(data); + case TypeCode.BYTES: + return decodeBytes(data); + + case TypeCode.CLASS: { + // The wire form of a CLASS is a uint64 object id; 0 = None. + // We decode as a BigInt so callers can pass the id back as an + // argument to other class methods. + const id = bigFromVarint(data); + return id === 0n ? null : id; + } + + case TypeCode.ENUMERATION: { + // Wire form: a single signed varint (sint32 in protobuf terms). + return decodeSint32(data); + } + + case TypeCode.STATUS: { + return decodeSystemMessage('Status', data, messageTypes); + } + case TypeCode.SERVICES: { + return decodeSystemMessage('Services', data, messageTypes); + } + case TypeCode.STREAM: { + return decodeSystemMessage('Stream', data, messageTypes); + } + case TypeCode.EVENT: { + return decodeSystemMessage('Event', data, messageTypes); + } + case TypeCode.PROCEDURE_CALL: { + return decodeSystemMessage('ProcedureCall', data, messageTypes); + } + + case TypeCode.LIST: + return decodeList(type, data, messageTypes); + case TypeCode.SET: + return decodeSet(type, data, messageTypes); + case TypeCode.TUPLE: + return decodeTuple(type, data, messageTypes); + case TypeCode.DICTIONARY: + return decodeDictionary(type, data, messageTypes); + + default: + throw new Error(`unknown TypeCode ${type.code} (${typeName(type)})`); + } +} + +// ── primitive decoders ────────────────────────────────────────────────────── + +/** + * Read a fixed-width little-endian IEEE 754 number from a buffer. + * + * JavaScript's `DataView.getFloat64()` already does the right thing on + * little-endian platforms, which Node always is. We still go through + * `DataView` so the intent is explicit and the code is portable to + * big-endian platforms (if we ever run on one). + */ +function readFloatLE(buf: Uint8Array, offset: number, bytes: 4 | 8): number { + // Pad short reads with zero bytes. This shouldn't happen in practice, + // but it's defensive against a truncated response. + if (buf.length < offset + bytes) { + const padded = new Uint8Array(bytes); + padded.set(buf.subarray(offset, offset + bytes)); + buf = padded; + offset = 0; + } + const view = new DataView(buf.buffer, buf.byteOffset + offset, bytes); + return bytes === 8 ? view.getFloat64(0, true) : view.getFloat32(0, true); +} + +export function decodeDouble(data: Uint8Array): number { + return readFloatLE(data, 0, 8); +} + +export function decodeFloat(data: Uint8Array): number { + return readFloatLE(data, 0, 4); +} + +export function decodeSint32(data: Uint8Array): number { + // Protobuf sint32 is zigzag-encoded. `decodeVarint` returns a regular + // varint, so we need the zigzag step too. + const [raw] = decodeVarint(Buffer.from(data)); + return zigzagDecode(Number(raw)); +} + +export function decodeSint64(data: Uint8Array): number { + // The Python client decodes sint64 to a plain int (BigInt in their + // case is a separate branch). For our use case the values we care + // about (enum cases) are well within int32 range, so we decode to + // a JS number. If you really need full int64, decode to BigInt. + return decodeSint32(data); +} + +export function decodeUint32(data: Uint8Array): number { + const [v] = decodeVarint(Buffer.from(data)); + return Number(v); +} + +export function decodeUint64(data: Uint8Array): bigint { + // Use BigInt for the 64-bit case. The kRPC ObjectId and StreamId are + // uint64s and can exceed Number.MAX_SAFE_INTEGER in principle + // (though kRPC never generates ids that large in practice). + return bigFromVarint(data); +} + +export function decodeBool(data: Uint8Array): boolean { + if (data.length < 1) return false; + return data[0] !== 0; +} + +export function decodeString(data: Uint8Array): string { + // Wire form: [varint length][utf8 bytes]. + const buf = Buffer.from(data); + const [len, pos] = decodeVarint(buf); + return buf.subarray(pos, pos + Number(len)).toString('utf-8'); +} + +export function decodeBytes(data: Uint8Array): Uint8Array { + const buf = Buffer.from(data); + const [len, pos] = decodeVarint(buf); + return new Uint8Array(buf.subarray(pos, pos + Number(len))); +} + +function zigzagDecode(n: number): number { + return (n >>> 1) ^ -(n & 1); +} + +/** + * Decode a varint (assumed ≤ 64 bits) as a BigInt. Used for uint64 values + * where the precision loss of Number() is unacceptable. + */ +function bigFromVarint(data: Uint8Array): bigint { + let result = 0n; + let shift = 0n; + for (let i = 0; i < data.length; i++) { + const b = data[i]; + if (b === undefined) break; + result |= BigInt(b & 0x7f) << shift; + if ((b & 0x80) === 0) return result; + shift += 7n; + } + return result; +} + +// ── collection decoders ───────────────────────────────────────────────────── + +/** + * Decode a KRPC.List message (when its serialized form is provided). + * Used internally by `decodeList`. Also exported for tests. + */ +export function decodeKrpcList( + data: Uint8Array, + messageType: protobuf.Type, +): Uint8Array[] { + if (data.length === 1 && data[0] === 0) { + // A list may be serialized as a single 0x00 byte to indicate None. + return []; + } + const msg = messageType.decode(data) as unknown as { items: Uint8Array[] }; + return msg.items ?? []; +} + +export function decodeList( + type: KrpcType, + data: Uint8Array, + messageTypes?: Record, +): DecodedValue[] { + if (data.length === 1 && data[0] === 0) return null as unknown as DecodedValue[]; + const listType = messageTypes?.['List']; + if (!listType) { + throw new Error('decodeList: no protobufjs type for KRPC.List registered'); + } + const items = decodeKrpcList(data, listType); + const elemType = type.types[0]; + if (!elemType) throw new Error('LIST type missing element type'); + return items.map((it) => decodeValue(elemType, it, messageTypes)); +} + +export function decodeSet( + type: KrpcType, + data: Uint8Array, + messageTypes?: Record, +): Set { + if (data.length === 1 && data[0] === 0) return null as unknown as Set; + const setType = messageTypes?.['Set']; + if (!setType) { + throw new Error('decodeSet: no protobufjs type for KRPC.Set registered'); + } + const msg = setType.decode(data) as unknown as { items: Uint8Array[] }; + const elemType = type.types[0]; + if (!elemType) throw new Error('SET type missing element type'); + return new Set((msg.items ?? []).map((it) => decodeValue(elemType, it, messageTypes))); +} + +export function decodeTuple( + type: KrpcType, + data: Uint8Array, + messageTypes?: Record, +): DecodedValue[] { + if (data.length === 1 && data[0] === 0) return null as unknown as DecodedValue[]; + const tupleType = messageTypes?.['Tuple']; + if (!tupleType) { + throw new Error('decodeTuple: no protobufjs type for KRPC.Tuple registered'); + } + const msg = tupleType.decode(data) as unknown as { items: Uint8Array[] }; + return type.types.map((inner, i) => + decodeValue(inner, msg.items[i] ?? new Uint8Array(0), messageTypes), + ); +} + +export function decodeDictionary( + type: KrpcType, + data: Uint8Array, + messageTypes?: Record, +): Map { + if (data.length === 1 && data[0] === 0) { + return null as unknown as Map; + } + const dictType = messageTypes?.['Dictionary']; + if (!dictType) { + throw new Error('decodeDictionary: no protobufjs type for KRPC.Dictionary registered'); + } + const msg = dictType.decode(data) as unknown as { + entries: { key: Uint8Array; value: Uint8Array }[]; + }; + const keyType = type.types[0]; + const valType = type.types[1]; + if (!keyType || !valType) throw new Error('DICTIONARY type missing key/value types'); + const out = new Map(); + for (const e of msg.entries ?? []) { + out.set( + decodeValue(keyType, e.key, messageTypes), + decodeValue(valType, e.value, messageTypes), + ); + } + return out; +} + +function decodeSystemMessage( + name: string, + data: Uint8Array, + messageTypes?: Record, +): { [k: string]: unknown } { + const t = messageTypes?.[name]; + if (!t) throw new Error(`decodeSystemMessage: no type registered for ${name}`); + return t.decode(data) as unknown as { [k: string]: unknown }; +} + +// ── encoders (mirrors, for sending arguments) ─────────────────────────────── + +/** + * Encode a value into the kRPC wire format for `type`. + * + * This is the inverse of `decodeValue`. Used by the service client to + * serialize procedure arguments. Collections and system messages use the + * protobufjs registry the same way the decoder does. + */ +export function encodeValue( + type: KrpcType, + value: DecodedValue, + messageTypes?: Record, +): Uint8Array { + switch (type.code) { + case TypeCode.NONE: + return new Uint8Array(0); + case TypeCode.DOUBLE: + return encodeDouble(value as number); + case TypeCode.FLOAT: + return encodeFloat(value as number); + case TypeCode.SINT32: + return encodeSint32(value as number); + case TypeCode.SINT64: + return encodeSint32(value as number); + case TypeCode.UINT32: + return encodeUint32(value as number); + case TypeCode.UINT64: + return encodeUint64(value as bigint); + case TypeCode.BOOL: + return encodeBool(value as boolean); + case TypeCode.STRING: + return encodeString(value as string); + case TypeCode.BYTES: + return encodeBytes(value as Uint8Array); + + case TypeCode.CLASS: { + // CLASS args are encoded as uint64 object id; null = 0. + if (value === null || value === undefined) return encodeUint64(0n); + if (typeof value === 'bigint') return encodeUint64(value); + if (typeof value === 'number') return encodeUint64(BigInt(value)); + throw new Error(`encodeValue: CLASS expects bigint object id, got ${typeof value}`); + } + + case TypeCode.ENUMERATION: + return encodeSint32(value as number); + + case TypeCode.LIST: { + const listType = messageTypes?.['List']; + if (!listType) throw new Error('encodeValue: no type for KRPC.List'); + const elemType = type.types[0]; + if (!elemType) throw new Error('LIST type missing element type'); + const items = (value as DecodedValue[]).map((v) => encodeValue(elemType, v, messageTypes)); + const msg = listType.create({ items }); + return listType.encode(msg).finish(); + } + case TypeCode.TUPLE: { + const tupleType = messageTypes?.['Tuple']; + if (!tupleType) throw new Error('encodeValue: no type for KRPC.Tuple'); + const items = (value as DecodedValue[]).map((v, i) => + encodeValue(type.types[i] as KrpcType, v, messageTypes), + ); + const msg = tupleType.create({ items }); + return tupleType.encode(msg).finish(); + } + case TypeCode.DICTIONARY: { + const dictType = messageTypes?.['Dictionary']; + if (!dictType) throw new Error('encodeValue: no type for KRPC.Dictionary'); + const keyType = type.types[0]; + const valType = type.types[1]; + if (!keyType || !valType) throw new Error('DICTIONARY type missing key/value types'); + const entries: { key: Uint8Array; value: Uint8Array }[] = []; + for (const [k, v] of (value as Map).entries()) { + entries.push({ + key: encodeValue(keyType, k, messageTypes), + value: encodeValue(valType, v, messageTypes), + }); + } + const msg = dictType.create({ entries }); + return dictType.encode(msg).finish(); + } + case TypeCode.SET: { + // kRPC 0.5 doesn't really use SET much; if needed we'd encode as + // KRPC.Set. For now, only LIST/TUPLE/DICT are exercised by our + // SpaceCenter calls. + throw new Error('encodeValue: SET not implemented (kRPC 0.5 does not use it)'); + } + case TypeCode.STATUS: + case TypeCode.SERVICES: + case TypeCode.STREAM: + case TypeCode.EVENT: + case TypeCode.PROCEDURE_CALL: + throw new Error(`encodeValue: system message ${typeName(type)} cannot be sent as argument`); + + default: + throw new Error(`encodeValue: unknown TypeCode ${type.code}`); + } +} + +function encodeDouble(v: number): Uint8Array { + const out = new Uint8Array(8); + new DataView(out.buffer).setFloat64(0, v, true); + return out; +} + +function encodeFloat(v: number): Uint8Array { + const out = new Uint8Array(4); + new DataView(out.buffer).setFloat32(0, v, true); + return out; +} + +function encodeSint32(v: number): Uint8Array { + return encodeVarint(zigzagEncode(v)); +} + +function encodeUint32(v: number): Uint8Array { + return encodeVarint(v); +} + +function encodeUint64(v: bigint): Uint8Array { + // Manual varint encoding for BigInt to avoid Number truncation. + const out: number[] = []; + let x = v; + while (x >= 0x80n) { + out.push(Number((x & 0x7fn) | 0x80n)); + x >>= 7n; + } + out.push(Number(x)); + return new Uint8Array(out); +} + +function encodeBool(v: boolean): Uint8Array { + return new Uint8Array([v ? 1 : 0]); +} + +function encodeString(v: string): Uint8Array { + const utf8 = new TextEncoder().encode(v); + return Buffer.concat([encodeVarint(utf8.length), Buffer.from(utf8)]); +} + +function encodeBytes(v: Uint8Array): Uint8Array { + return Buffer.concat([encodeVarint(v.length), Buffer.from(v)]); +} + +function zigzagEncode(n: number): number { + return (n << 1) ^ (n >> 31); +} diff --git a/packages/krpc-client/src/index.ts b/packages/krpc-client/src/index.ts index e70bdc0..5779575 100644 --- a/packages/krpc-client/src/index.ts +++ b/packages/krpc-client/src/index.ts @@ -7,10 +7,16 @@ * - sendMessage / recvMessage / recvRawMessage / encodeVarint / decodeVarint: * low-level wire-format helpers * - KRPC namespace: protobufjs types for the kRPC meta-protocol + * - KrpcType / TypeCode: runtime representation of kRPC type descriptors + * - decodeValue / encodeValue: kRPC value codec (primitives, classes, + * enums, collections, system messages) + * - ServiceCache: a Type index built from KRPC.GetServices() + * - KrpcServices: high-level invoke-by-name client * - * See ./schema.ts for the meta schema. The service-specific types - * (SpaceCenter.Vessel, Orbit, etc.) need to be loaded from the kRPC - * mod's .proto files at runtime when running against a real KSP. + * For the service-specific types (SpaceCenter.Vessel, Orbit, etc.) we do + * NOT need to load the kRPC mod's .proto files. The server's + * GetServices() response contains everything we need to encode/decode + * values. See ./service-client.ts for the details. */ export { KRPCClient, @@ -26,4 +32,27 @@ export { decodeVarint, tcpConnect, } from './connection.js'; -export { KRPC, encodeMessage, decodeMessage } from './schema.js'; +export { KRPC, encodeMessage, decodeMessage, MESSAGE_TYPES } from './schema.js'; +export { + TypeCode, + decodeKrpcType, + typeName, + type KrpcType, + type TypeCodeValue, + type RawKrpcTypeMessage, +} from './types.js'; +export { + decodeValue, + encodeValue, + decodeDouble, + decodeFloat, + decodeSint32, + decodeUint32, + decodeUint64, + decodeBool, + decodeString, + decodeBytes, + type DecodedValue, +} from './decoder.js'; +export { ServiceCache } from './services.js'; +export { KrpcServices, loadServices, type KrpcInvokeError } from './service-client.js'; diff --git a/packages/krpc-client/src/schema.ts b/packages/krpc-client/src/schema.ts index 68ef4f1..5aa6b23 100644 --- a/packages/krpc-client/src/schema.ts +++ b/packages/krpc-client/src/schema.ts @@ -245,6 +245,12 @@ const schemaJson = { Event: { fields: { stream: { type: 'Stream', id: 1 } }, }, + Expression: { + fields: { + typ: { type: 'Type', id: 1 }, + code: { type: 'string', id: 2 }, + }, + }, }, }, }, @@ -253,31 +259,73 @@ const schemaJson = { }; const root = protobuf.Root.fromJSON(schemaJson as protobuf.INamespace); -// Cache the resolved types for fast lookup const ns = root.lookup('krpc.schema') as protobuf.Namespace; -// Suppress "type X is not used" warnings for the namespace -void ns; -void root; +const lookupType = (name: string): protobuf.Type => + ns.lookupType(name) as protobuf.Type; + +/** + * All kRPC meta-protocol types, by their protobufjs Type objects. + * + * These are used by the decoder/encoder for system messages (Status, + * Services, Stream, Event, ProcedureCall) and collection types + * (List, Set, Tuple, Dictionary). + * + * The same Type objects are also exposed as `MESSAGE_TYPES` so the + * service client can register them with the decoder in one call. + * + * GOTCHA: protobufjs's nested-enum string-to-number lookup is brittle + * when the enum shares its name with a built-in JavaScript type or + * with a parent property. In particular, sending + * encodeMessage(ConnectionRequest, { type: 'STREAM' }) + * silently encodes as 0 (RPC) instead of 1. To avoid this, encode + * enum values by their numeric code rather than the string name. + */ export const KRPC = { - ConnectionRequest: ns.lookupType('ConnectionRequest'), - ConnectionResponse: ns.lookupType('ConnectionResponse'), - Request: ns.lookupType('Request'), - Response: ns.lookupType('Response'), - ProcedureCall: ns.lookupType('ProcedureCall'), - Argument: ns.lookupType('Argument'), - ProcedureResult: ns.lookupType('ProcedureResult'), - Error: ns.lookupType('Error'), - StreamUpdate: ns.lookupType('StreamUpdate'), - StreamResult: ns.lookupType('StreamResult'), - Stream: ns.lookupType('Stream'), - Status: ns.lookupType('Status'), - Services: ns.lookupType('Services'), - Service: ns.lookupType('Service'), - Type: ns.lookupType('Type'), - List: ns.lookupType('List'), - Tuple: ns.lookupType('Tuple'), + ConnectionRequest: lookupType('ConnectionRequest'), + ConnectionResponse: lookupType('ConnectionResponse'), + Request: lookupType('Request'), + Response: lookupType('Response'), + ProcedureCall: lookupType('ProcedureCall'), + Argument: lookupType('Argument'), + ProcedureResult: lookupType('ProcedureResult'), + Error: lookupType('Error'), + StreamUpdate: lookupType('StreamUpdate'), + StreamResult: lookupType('StreamResult'), + Stream: lookupType('Stream'), + Status: lookupType('Status'), + Services: lookupType('Services'), + Service: lookupType('Service'), + Type: lookupType('Type'), + List: lookupType('List'), + Set: lookupType('Set'), + Tuple: lookupType('Tuple'), + Dictionary: lookupType('Dictionary'), + DictionaryEntry: lookupType('DictionaryEntry'), + Event: lookupType('Event'), + Expression: lookupType('Expression'), } as const; +/** + * A name → protobufjs Type registry for system messages and collection + * types. The decoder/encoder accepts this as the `messageTypes` argument + * so it knows how to (de)serialize these specific messages. + */ +export const MESSAGE_TYPES: Record = { + List: KRPC.List, + Set: KRPC.Set, + Tuple: KRPC.Tuple, + Dictionary: KRPC.Dictionary, + Status: KRPC.Status, + Services: KRPC.Services, + Stream: KRPC.Stream, + Event: KRPC.Event, + ProcedureCall: KRPC.ProcedureCall, +}; + +// Silence "type not used" — we keep the root reference for diagnostics +// and to allow callers to load additional .proto files later if needed. +void root; + /** Encode a length-prefixed protobuf message. */ export function encodeMessage(type: protobuf.Type, value: Record): Buffer { return Buffer.from(type.encode(type.create(value)).finish()); diff --git a/packages/krpc-client/src/service-client.ts b/packages/krpc-client/src/service-client.ts new file mode 100644 index 0000000..2110c06 --- /dev/null +++ b/packages/krpc-client/src/service-client.ts @@ -0,0 +1,196 @@ +/** + * KrpcServices — high-level service client. + * + * Wraps KRPCClient + ServiceCache to provide a clean invoke-by-name API: + * + * const sc = new KrpcServices(client, cache); + * const ut = await sc.invoke('SpaceCenter', 'GetUT'); + * const bodyIds = await sc.invoke('SpaceCenter', 'GetBodies'); + * const name = await sc.invoke('SpaceCenter', 'CelestialBody.GetName', bodyId); + * + * The client looks up the procedure in the cache, encodes each argument + * using the procedure's parameter types, calls the procedure via the + * low-level client, then decodes the response using the return type. + * + * For class-returning procedures, the response is decoded as the object + * id (a BigInt), or `null` if the server returned id=0. The caller can + * then pass that BigInt to other class methods. + * + * This design means we don't need the kRPC mod's .proto files at all — + * the kRPC server provides the type information via GetServices(), and + * the kRPC value encoding is what our decoder handles. + */ +import type { KRPCClient, ProcedureCallRequest } from './client.js'; +import { decodeValue, encodeValue, type DecodedValue } from './decoder.js'; +import { MESSAGE_TYPES, KRPC, decodeMessage } from './schema.js'; +import type { KrpcType } from './types.js'; +import { ServiceCache, type RawServicesMessage } from './services.js'; + +export interface KrpcInvokeError extends Error { + service: string; + name: string; + description: string; + stackTrace?: string; +} + +/** Build a KrpcInvokeError with extra metadata attached. */ +function makeInvokeError( + service: string, + name: string, + description: string, + stackTrace?: string, +): KrpcInvokeError { + const e = new Error(`${service}.${name}: ${description}`) as KrpcInvokeError; + e.service = service; + e.name = name; + e.description = description; + if (stackTrace) e.stackTrace = stackTrace; + return e; +} + +export class KrpcServices { + constructor( + private readonly client: KRPCClient, + private readonly cache: ServiceCache, + ) {} + + /** + * Invoke a procedure and return the decoded value. + * + * @param service e.g. "SpaceCenter" + * @param procedure e.g. "GetUT" or "CelestialBody.GetName" for class methods + * @param args The procedure arguments. Each is encoded according to the + * procedure's parameter types (looked up from the cache). + * Object ids for CLASS parameters are BigInts. + */ + async invoke( + service: string, + procedure: string, + ...args: DecodedValue[] + ): Promise { + const lookup = this.cache.lookup(service, procedure); + if (!lookup.found) { + throw makeInvokeError( + service, + procedure, + `procedure not found in service cache (known services: ${this.cache.serviceNames().join(', ')})`, + ); + } + const info = lookup.info; + if (args.length !== info.parameters.length) { + throw makeInvokeError( + service, + procedure, + `wrong number of arguments: expected ${info.parameters.length}, got ${args.length}`, + ); + } + const encodedArgs = info.parameters.map((p, i) => { + const v = args[i]; + // For nullable CLASS parameters, accept `null`/`undefined` and encode as id=0. + if (p.nullable && (v === null || v === undefined)) { + return new Uint8Array(0); + } + return encodeValue(p.type, v, MESSAGE_TYPES); + }); + // Low-level invoke needs Uint8Array values for each argument. + const req: ProcedureCallRequest = { + service: info.service, + procedure: info.name, + args: encodedArgs, + }; + let rawValue: Uint8Array; + try { + rawValue = await this.client.invoke(req); + } catch (err) { + // The low-level client throws with a generic message; we wrap it + // with the service.procedure prefix so the caller knows which call + // failed. The original error is on the `cause` chain in newer + // Node, but to keep things simple we re-throw a tagged error. + const e = err as Error; + throw makeInvokeError(service, procedure, e.message, e.stack); + } + if (rawValue.length === 0) { + // Zero-length response. Only valid for: + // - procedures with no return value (KrpcType NONE) + // - nullable CLASS with id=0 — but that is 1 byte (0x00), not 0 + // So we return null if the return type is NONE or nullable, + // otherwise throw. + if (info.returnType.code === 0 /* NONE */) { + return null as unknown as T; + } + if (info.returnIsNullable) { + return null as unknown as T; + } + throw makeInvokeError( + service, + procedure, + 'zero-length response for non-nullable, non-NONE return type', + ); + } + const decoded = decodeValue(info.returnType, rawValue, MESSAGE_TYPES); + if (decoded === null && !info.returnIsNullable) { + // Some primitives (e.g. uint64 = 0) might decode to falsy values + // that we don't want to confuse with null. But for CLASS/ENUM this + // is a real "no value" — and only valid for nullable returns. + throw makeInvokeError( + service, + procedure, + 'decoded null for non-nullable return type', + ); + } + return decoded as T; + } + + /** + * Read a class property by calling its getter procedure. Equivalent to + * invoke(service, "ClassName.GetPropName", objectId) + * but reads more naturally at the call site. + */ + async getClassProperty( + service: string, + className: string, + propertyName: string, + objectId: bigint | null, + ): Promise { + return this.invoke(service, `${className}.${propertyName}`, objectId as DecodedValue); + } + + /** + * Underlying service cache, exposed for callers that want to do + * procedural introspection (e.g. debug tools that list available + * services or resolve enum values). + */ + getCache(): ServiceCache { + return this.cache; + } +} + +/** + * Build a ServiceCache from a fresh KRPCClient. Convenience for the + * common "connect, get services, return ready client" pattern. + * + * The return value of `KRPC.GetServices()` is a serialized + * `KRPC.Services` message. We decode it using our protobufjs type + * definition, then convert the result to a `ServiceCache`. + */ +export async function loadServices(client: KRPCClient): Promise<{ + cache: ServiceCache; + services: KrpcServices; +}> { + const rawValue = await client.invoke({ + service: 'KRPC', + procedure: 'GetServices', + }); + // KRPC.Services is a system message — decode it using the + // registered protobufjs type. + const decoded = decodeMessage(KRPC.Services, rawValue); + // protobufjs decodes `services` field as a repeated Service; each + // Service has nested messages for Class, Enumeration, etc. that + // protobufjs also decodes. The shape matches our RawServicesMessage + // contract — but TypeScript doesn't know that, so we cast. + const cache = new ServiceCache(decoded); + return { cache, services: new KrpcServices(client, cache) }; +} + +/** Re-export for consumers that want to import KrpcType. */ +export type { KrpcType }; diff --git a/packages/krpc-client/src/services.ts b/packages/krpc-client/src/services.ts new file mode 100644 index 0000000..70b6d36 --- /dev/null +++ b/packages/krpc-client/src/services.ts @@ -0,0 +1,198 @@ +/** + * ServiceCache — a queryable index over a KRPC.GetServices() response. + * + * After connecting to kRPC, the canonical first call is `KRPC.GetServices()`, + * which returns a `KRPC.Services` message describing every service, every + * class, every enum, and every procedure. We decode that into a lookup + * table keyed by (service, procedure) so the service client can ask: + * + * - "what is the return type of SpaceCenter.GetBodies?" + * - "what are the param types of SpaceCenter.CelestialBody.GetName?" + * - "is SpaceCenter.VesselType.Ship == 0?" + * + * The cache is built once after connect, then read-only. It is decoupled + * from the network so it can be unit-tested by feeding in a hand-crafted + * Services message. + */ +import { + decodeKrpcType, + type KrpcType, + type RawKrpcTypeMessage, +} from './types.js'; + +/** Shape of the KRPC.GetServices() response after protobufjs decoding. */ +export interface RawServicesMessage { + services: RawServiceMessage[]; +} + +export interface RawServiceMessage { + name: string; + procedures: RawProcedureMessage[]; + classes: { name: string }[]; + enumerations: RawEnumerationMessage[]; +} + +export interface RawProcedureMessage { + name: string; + parameters: RawParameterMessage[]; + returnType: RawKrpcTypeMessage; + returnIsNullable: boolean; +} + +export interface RawParameterMessage { + name: string; + type: RawKrpcTypeMessage; + nullable: boolean; +} + +export interface RawEnumerationMessage { + name: string; + values: { name: string; value: number }[]; +} + +export interface ProcedureInfo { + service: string; + name: string; + /** Full procedure name with class prefix, e.g. `CelestialBody.GetName`. */ + fullName: string; + returnType: KrpcType; + returnIsNullable: boolean; + parameters: { name: string; type: KrpcType; nullable: boolean }[]; +} + +/** + * Result of a name lookup. Either we found the proc and we know its + * signature, or we didn't and the caller can decide what to do. + */ +export type ProcedureLookup = + | { found: true; info: ProcedureInfo } + | { found: false }; + +export class ServiceCache { + /** "SpaceCenter.CelestialBody.GetName" -> ProcedureInfo */ + private byFullName = new Map(); + /** "SpaceCenter" -> "SpaceCenter" (just the service name) */ + private services = new Set(); + /** "SpaceCenter.VesselType" -> "Ship" (enum name) -> int value */ + private enumValues = new Map>(); + /** "SpaceCenter" -> "VesselType" (enum name) -> Map */ + private enumsByService = new Map>>(); + + constructor(raw: RawServicesMessage) { + for (const svc of raw.services ?? []) { + this.services.add(svc.name); + for (const proc of svc.procedures ?? []) { + // proc.name already includes the class prefix when applicable + // (e.g. "CelestialBody.GetName"), per the kRPC wire format. + const info: ProcedureInfo = { + service: svc.name, + name: proc.name, + fullName: `${svc.name}.${proc.name}`, + returnType: decodeKrpcType(proc.returnType), + returnIsNullable: !!proc.returnIsNullable, + parameters: (proc.parameters ?? []).map((p) => ({ + name: p.name, + type: decodeKrpcType(p.type), + nullable: !!p.nullable, + })), + }; + this.byFullName.set(info.fullName, info); + } + for (const e of svc.enumerations ?? []) { + const m = new Map(); + for (const v of e.values ?? []) { + m.set(v.name, v.value); + } + const fq = `${svc.name}.${e.name}`; + this.enumValues.set(fq, m); + let inner = this.enumsByService.get(svc.name); + if (!inner) { + inner = new Map(); + this.enumsByService.set(svc.name, inner); + } + inner.set(e.name, m); + } + } + } + + /** All known service names, e.g. ["KRPC", "SpaceCenter", "KerbalAlarmClock", ...]. */ + serviceNames(): string[] { + return [...this.services].sort(); + } + + /** + * All procedure full names in a service, e.g. + * ["SpaceCenter.GetUT", "SpaceCenter.CelestialBody.GetName", ...]. + */ + proceduresInService(service: string): string[] { + const out: string[] = []; + for (const info of this.byFullName.values()) { + if (info.service === service) out.push(info.fullName); + } + return out.sort(); + } + + /** + * Look up a procedure by `service.procedure` (e.g. "SpaceCenter.GetUT") or + * by the class-prefixed form ("SpaceCenter.CelestialBody.GetName"). + */ + lookup(service: string, procedure: string): ProcedureLookup { + const info = this.byFullName.get(`${service}.${procedure}`); + if (!info) return { found: false }; + return { found: true, info }; + } + + /** + * Resolve an enum value name to its int code. e.g. + * getEnumValue("SpaceCenter", "VesselType", "Ship") -> 0 + * Throws if the enum or value is unknown. + */ + getEnumValue(service: string, enumName: string, valueName: string): number { + const m = this.enumValues.get(`${service}.${enumName}`); + if (!m) { + throw new Error(`unknown enum ${service}.${enumName}`); + } + const v = m.get(valueName); + if (v === undefined) { + throw new Error(`unknown value ${valueName} for ${service}.${enumName}`); + } + return v; + } + + /** + * Inverse: resolve an int code to a value name. Returns null if the + * code is not in the enum's range — kRPC may add new values in newer + * versions, so callers should be defensive. + */ + getEnumName( + service: string, + enumName: string, + valueCode: number, + ): string | null { + const m = this.enumValues.get(`${service}.${enumName}`); + if (!m) return null; + for (const [n, v] of m.entries()) { + if (v === valueCode) return n; + } + return null; + } + + /** + * All enum value names for an enum, e.g. + * getEnumNames("SpaceCenter", "VesselType") + * -> ["Ship", "Station", "Lander", "Probe", ...] + */ + getEnumNames(service: string, enumName: string): string[] { + const m = this.enumValues.get(`${service}.${enumName}`); + if (!m) return []; + return [...m.keys()].sort(); + } + + /** + * Count the number of distinct procedures across all services. + * Useful for tests and diagnostics. + */ + procedureCount(): number { + return this.byFullName.size; + } +} diff --git a/packages/krpc-client/src/types.ts b/packages/krpc-client/src/types.ts new file mode 100644 index 0000000..5d6db41 --- /dev/null +++ b/packages/krpc-client/src/types.ts @@ -0,0 +1,146 @@ +/** + * kRPC Type runtime representation. + * + * kRPC values are encoded on the wire in a custom way that sits on top of + * the standard protobuf encoding. Every procedure return / argument carries + * a `KrpcType` descriptor that tells the client how to encode/decode the + * bytes. The descriptor is itself a protobuf message (KRPC.Type) and is + * exchanged via `KRPC.GetServices()` at connect time. + * + * See: https://krpc.github.io/krpc/communication-protocols/messages.html + * and the Python client's `krpc.types` / `krpc.decoder` modules for the + * canonical reference implementation. + * + * TypeCode numeric values match the protobuf enum in krpc.proto: + * 0 NONE, 1 DOUBLE, 2 FLOAT, 3 SINT32, 4 SINT64, 5 UINT32, 6 UINT64, + * 7 BOOL, 8 STRING, 9 BYTES, + * 100 CLASS, 101 ENUMERATION, + * 200 EVENT, 201 PROCEDURE_CALL, 202 STREAM, 203 STATUS, 204 SERVICES, + * 300 TUPLE, 301 LIST, 302 SET, 303 DICTIONARY. + */ +export const TypeCode = { + NONE: 0, + DOUBLE: 1, + FLOAT: 2, + SINT32: 3, + SINT64: 4, + UINT32: 5, + UINT64: 6, + BOOL: 7, + STRING: 8, + BYTES: 9, + CLASS: 100, + ENUMERATION: 101, + EVENT: 200, + PROCEDURE_CALL: 201, + STREAM: 202, + STATUS: 203, + SERVICES: 204, + TUPLE: 300, + LIST: 301, + SET: 302, + DICTIONARY: 303, +} as const; + +export type TypeCodeValue = (typeof TypeCode)[keyof typeof TypeCode]; + +/** + * Decoded form of a kRPC Type message. We don't try to keep the + * protobufjs wrapper — the few fields we care about (code, service, name, + * types) are copied into this plain object for ergonomic access. + */ +export interface KrpcType { + code: TypeCodeValue; + /** For CLASS / ENUMERATION: the service that defines the type. */ + service: string; + /** For CLASS / ENUMERATION: the class/enum name. */ + name: string; + /** + * Nested types. Used by collections (LIST, SET, TUPLE, DICT). + * The semantics depend on the code: + * LIST / SET: types[0] is the element type + * TUPLE: types[i] is the i-th element type + * DICTIONARY: types[0] is the key type, types[1] is the value type + * For CLASS / ENUMERATION: empty. + */ + types: KrpcType[]; +} + +/** + * Decode a kRPC Type protobuf message into our plain KrpcType shape. + * Exposed so callers (e.g. the service cache) can transform the + * GetServices() response into a useful index. + */ +export interface RawKrpcTypeMessage { + code: number; + service: string; + name: string; + types: RawKrpcTypeMessage[]; +} + +export function decodeKrpcType(raw: RawKrpcTypeMessage): KrpcType { + return { + code: raw.code as TypeCodeValue, + service: raw.service ?? '', + name: raw.name ?? '', + types: (raw.types ?? []).map(decodeKrpcType), + }; +} + +/** + * Human-readable name for a typecode. Used in error messages and for + * debugging. Not used in wire encoding. + */ +export function typeName(t: KrpcType): string { + switch (t.code) { + case TypeCode.NONE: + return 'None'; + case TypeCode.DOUBLE: + return 'double'; + case TypeCode.FLOAT: + return 'float'; + case TypeCode.SINT32: + return 'sint32'; + case TypeCode.SINT64: + return 'sint64'; + case TypeCode.UINT32: + return 'uint32'; + case TypeCode.UINT64: + return 'uint64'; + case TypeCode.BOOL: + return 'bool'; + case TypeCode.STRING: + return 'string'; + case TypeCode.BYTES: + return 'bytes'; + case TypeCode.CLASS: + return `${t.service}.${t.name}`; + case TypeCode.ENUMERATION: + return `${t.service}.${t.name}`; + case TypeCode.EVENT: + return 'Event'; + case TypeCode.PROCEDURE_CALL: + return 'ProcedureCall'; + case TypeCode.STREAM: + return 'Stream'; + case TypeCode.STATUS: + return 'Status'; + case TypeCode.SERVICES: + return 'Services'; + case TypeCode.TUPLE: { + const inner = t.types.map(typeName).join(', '); + return `(${inner})`; + } + case TypeCode.LIST: + return `list<${t.types[0] ? typeName(t.types[0]) : '?'}>`; + case TypeCode.SET: + return `set<${t.types[0] ? typeName(t.types[0]) : '?'}>`; + case TypeCode.DICTIONARY: { + const k = t.types[0] ? typeName(t.types[0]) : '?'; + const v = t.types[1] ? typeName(t.types[1]) : '?'; + return `dict<${k}, ${v}>`; + } + default: + return `code=${t.code}`; + } +} diff --git a/packages/krpc-client/tests/decoder.test.ts b/packages/krpc-client/tests/decoder.test.ts new file mode 100644 index 0000000..b959281 --- /dev/null +++ b/packages/krpc-client/tests/decoder.test.ts @@ -0,0 +1,346 @@ +import { describe, it, expect } from 'vitest'; +import protobuf from 'protobufjs'; +import { + decodeValue, + encodeValue, + decodeDouble, + decodeFloat, + decodeSint32, + decodeUint32, + decodeUint64, + decodeBool, + decodeString, + decodeBytes, + decodeList, + decodeTuple, + decodeDictionary, + decodeKrpcList, +} from '../src/decoder.js'; +import { TypeCode, type KrpcType } from '../src/types.js'; +import { MESSAGE_TYPES, KRPC } from '../src/schema.js'; + +const T = (t: Partial): KrpcType => ({ + code: t.code ?? 0, + service: t.service ?? '', + name: t.name ?? '', + types: t.types ?? [], +}); + +// We need a small fake "TYPE" registry for the tests so the decoder +// can find List / Tuple / Dictionary / Status by name. We can use the +// real MESSAGE_TYPES for the actual system messages. +const REG = MESSAGE_TYPES; + +describe('primitive decoders', () => { + it('decodes a double (8-byte little-endian)', () => { + expect(decodeDouble(new Uint8Array(new Float64Array([3.14]).buffer))).toBeCloseTo(3.14, 10); + expect(decodeDouble(new Uint8Array(new Float64Array([-0]).buffer))).toBe(-0); + expect(decodeDouble(new Uint8Array(new Float64Array([0]).buffer))).toBe(0); + expect(decodeDouble(new Uint8Array(new Float64Array([1e30]).buffer))).toBe(1e30); + }); + + it('decodes a float (4-byte little-endian)', () => { + expect(decodeFloat(new Uint8Array(new Float32Array([2.5]).buffer))).toBeCloseTo(2.5, 5); + }); + + it('decodes a sint32 (zigzag varint)', () => { + // 0 -> 0, 1 -> 2, -1 -> 1, 2 -> 4, -2 -> 3 + expect(decodeSint32(new Uint8Array([0]))).toBe(0); + expect(decodeSint32(new Uint8Array([2]))).toBe(1); + expect(decodeSint32(new Uint8Array([1]))).toBe(-1); + expect(decodeSint32(new Uint8Array([4]))).toBe(2); + expect(decodeSint32(new Uint8Array([3]))).toBe(-2); + }); + + it('decodes a uint32 (varint)', () => { + expect(decodeUint32(new Uint8Array([0]))).toBe(0); + expect(decodeUint32(new Uint8Array([0x7f]))).toBe(127); + expect(decodeUint32(new Uint8Array([0x80, 0x01]))).toBe(128); + }); + + it('decodes a uint64 to BigInt (preserves > 2^53)', () => { + // 2^53 + 1 exceeds Number.MAX_SAFE_INTEGER. Verify the decoder + // produces the exact BigInt. Hand-varint for 2^53+1: + // 7-bit groups, LSB first: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x10 + // (bit 53 = position 4 in the last group = 0b0010000 = 0x10) + const big = 0x20_0000_0000_0001n; // 2^53 + 1 + const bytes = new Uint8Array([0x81, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10]); + expect(decodeUint64(bytes)).toBe(big); + }); + + it('decodes a bool', () => { + expect(decodeBool(new Uint8Array([0]))).toBe(false); + expect(decodeBool(new Uint8Array([1]))).toBe(true); + expect(decodeBool(new Uint8Array([0xff]))).toBe(true); + }); + + it('decodes a string', () => { + const utf8 = new TextEncoder().encode('Kerbin'); + const buf = new Uint8Array([utf8.length, ...utf8]); + expect(decodeString(buf)).toBe('Kerbin'); + }); + + it('decodes a string with non-ASCII characters', () => { + const utf8 = new TextEncoder().encode('日本語'); + const buf = new Uint8Array([utf8.length, ...utf8]); + expect(decodeString(buf)).toBe('日本語'); + }); + + it('decodes bytes', () => { + const payload = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + const buf = new Uint8Array([payload.length, ...payload]); + expect(decodeBytes(buf)).toEqual(payload); + }); +}); + +describe('decodeValue dispatcher', () => { + it('decodes DOUBLE', () => { + const bytes = new Uint8Array(new Float64Array([2.71828]).buffer); + const out = decodeValue(T({ code: TypeCode.DOUBLE }), bytes); + expect(out).toBeCloseTo(2.71828, 5); + }); + + it('decodes STRING', () => { + const utf8 = new TextEncoder().encode('Mun'); + const bytes = new Uint8Array([utf8.length, ...utf8]); + expect(decodeValue(T({ code: TypeCode.STRING }), bytes)).toBe('Mun'); + }); + + it('decodes CLASS object id (non-null)', () => { + // 42 as varint + const id = decodeValue(T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'Vessel' }), new Uint8Array([42])); + expect(id).toBe(42n); + }); + + it('decodes CLASS object id (null when 0)', () => { + expect( + decodeValue( + T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'Vessel' }), + new Uint8Array([0]), + ), + ).toBeNull(); + }); + + it('decodes ENUMERATION as a sint32', () => { + expect( + decodeValue( + T({ code: TypeCode.ENUMERATION, service: 'SpaceCenter', name: 'VesselType' }), + new Uint8Array([0]), // Ship + ), + ).toBe(0); + }); + + it('decodes NONE as null', () => { + expect(decodeValue(T({ code: TypeCode.NONE }), new Uint8Array(0))).toBeNull(); + }); + + it('throws on unknown type code', () => { + expect(() => decodeValue(T({ code: 9999 }), new Uint8Array(0))).toThrow(/unknown TypeCode/); + }); +}); + +describe('collection decoders', () => { + it('decodes a list of doubles', () => { + // Construct a KRPC.List message manually: + // items[0] = 8 bytes for double 1.0 + // items[1] = 8 bytes for double 2.5 + const d1 = new Uint8Array(new Float64Array([1.0]).buffer); + const d2 = new Uint8Array(new Float64Array([2.5]).buffer); + const listMsg = KRPC.List.create({ items: [d1, d2] }); + const listBytes = KRPC.List.encode(listMsg).finish(); + const out = decodeList( + T({ code: TypeCode.LIST, types: [T({ code: TypeCode.DOUBLE })] }), + listBytes, + REG, + ); + expect(out).toEqual([1.0, 2.5]); + }); + + it('decodes a list of strings', () => { + const enc = (s: string): Uint8Array => { + const utf8 = new TextEncoder().encode(s); + return new Uint8Array([utf8.length, ...utf8]); + }; + const listMsg = KRPC.List.create({ items: [enc('Kerbin'), enc('Mun')] }); + const listBytes = KRPC.List.encode(listMsg).finish(); + const out = decodeList( + T({ code: TypeCode.LIST, types: [T({ code: TypeCode.STRING })] }), + listBytes, + REG, + ); + expect(out).toEqual(['Kerbin', 'Mun']); + }); + + it('decodes an empty list', () => { + const listMsg = KRPC.List.create({ items: [] }); + const listBytes = KRPC.List.encode(listMsg).finish(); + const out = decodeList( + T({ code: TypeCode.LIST, types: [T({ code: TypeCode.STRING })] }), + listBytes, + REG, + ); + expect(out).toEqual([]); + }); + + it('decodes a null list as null', () => { + const out = decodeList( + T({ code: TypeCode.LIST, types: [T({ code: TypeCode.STRING })] }), + new Uint8Array([0]), + REG, + ); + expect(out).toBeNull(); + }); + + it('decodes a list of CLASS as a bigint array', () => { + // items[0] = 7, items[1] = 0 (null) + const listMsg = KRPC.List.create({ items: [new Uint8Array([7]), new Uint8Array([0])] }); + const listBytes = KRPC.List.encode(listMsg).finish(); + const out = decodeList( + T({ + code: TypeCode.LIST, + types: [T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'CelestialBody' })], + }), + listBytes, + REG, + ); + expect(out).toEqual([7n, null]); + }); + + it('decodes a tuple of mixed types', () => { + // tuple: (string "Kerbin", double 0.5) + const utf8 = new TextEncoder().encode('Kerbin'); + const sBytes = new Uint8Array([utf8.length, ...utf8]); + const dBytes = new Uint8Array(new Float64Array([0.5]).buffer); + const tupleMsg = KRPC.Tuple.create({ items: [sBytes, dBytes] }); + const tupleBytes = KRPC.Tuple.encode(tupleMsg).finish(); + const out = decodeTuple( + T({ + code: TypeCode.TUPLE, + types: [T({ code: TypeCode.STRING }), T({ code: TypeCode.DOUBLE })], + }), + tupleBytes, + REG, + ); + expect(out).toEqual(['Kerbin', 0.5]); + }); + + it('decodes a dictionary of string -> double', () => { + const utf8 = new TextEncoder().encode('mu'); + const sBytes = new Uint8Array([utf8.length, ...utf8]); + const dBytes = new Uint8Array(new Float64Array([3.53e12]).buffer); + const dictMsg = KRPC.Dictionary.create({ + entries: [{ key: sBytes, value: dBytes }], + }); + const dictBytes = KRPC.Dictionary.encode(dictMsg).finish(); + const out = decodeDictionary( + T({ + code: TypeCode.DICTIONARY, + types: [T({ code: TypeCode.STRING }), T({ code: TypeCode.DOUBLE })], + }), + dictBytes, + REG, + ); + expect(out.get('mu')).toBe(3.53e12); + }); + + it('decodes a null dictionary as null', () => { + const out = decodeDictionary( + T({ + code: TypeCode.DICTIONARY, + types: [T({ code: TypeCode.STRING }), T({ code: TypeCode.DOUBLE })], + }), + new Uint8Array([0]), + REG, + ); + expect(out).toBeNull(); + }); + + it('exposes a low-level decodeKrpcList for testing', () => { + const listMsg = KRPC.List.create({ items: [new Uint8Array([1, 2, 3])] }); + const bytes = KRPC.List.encode(listMsg).finish(); + const out = decodeKrpcList(bytes, KRPC.List); + // protobufjs returns Node Buffers (which extend Uint8Array). Compare + // the underlying bytes so this works regardless of wrapper class. + expect(out).toHaveLength(1); + expect(Array.from(out[0] as Uint8Array)).toEqual([1, 2, 3]); + }); +}); + +describe('encodeValue (round-trips)', () => { + it('encodes a double', () => { + const bytes = encodeValue(T({ code: TypeCode.DOUBLE }), 1.5); + expect(decodeValue(T({ code: TypeCode.DOUBLE }), bytes)).toBe(1.5); + }); + + it('encodes a string', () => { + const bytes = encodeValue(T({ code: TypeCode.STRING }), 'Kerbol'); + expect(decodeValue(T({ code: TypeCode.STRING }), bytes)).toBe('Kerbol'); + }); + + it('encodes a CLASS object id', () => { + const bytes = encodeValue( + T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'Vessel' }), + 42n, + ); + expect(decodeValue(T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'Vessel' }), bytes)).toBe(42n); + }); + + it('encodes a null CLASS as id=0', () => { + const bytes = encodeValue( + T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'Vessel' }), + null, + ); + expect(bytes).toEqual(new Uint8Array([0])); + }); + + it('encodes an ENUMERATION', () => { + const bytes = encodeValue( + T({ code: TypeCode.ENUMERATION, service: 'SpaceCenter', name: 'VesselType' }), + 3, // Probe + ); + expect( + decodeValue( + T({ code: TypeCode.ENUMERATION, service: 'SpaceCenter', name: 'VesselType' }), + bytes, + ), + ).toBe(3); + }); + + it('encodes a list of doubles', () => { + const bytes = encodeValue( + T({ code: TypeCode.LIST, types: [T({ code: TypeCode.DOUBLE })] }), + [1.0, 2.0, 3.0], + REG, + ); + const out = decodeValue( + T({ code: TypeCode.LIST, types: [T({ code: TypeCode.DOUBLE })] }), + bytes, + REG, + ); + expect(out).toEqual([1.0, 2.0, 3.0]); + }); + + it('encodes a uint64 BigInt', () => { + const id = 0x1_0000_0000n; // 2^32 + const bytes = encodeValue(T({ code: TypeCode.UINT64 }), id); + expect(decodeValue(T({ code: TypeCode.UINT64 }), bytes)).toBe(id); + }); + + it('encodes a bool', () => { + expect(decodeValue(T({ code: TypeCode.BOOL }), encodeValue(T({ code: TypeCode.BOOL }), true))).toBe(true); + expect(decodeValue(T({ code: TypeCode.BOOL }), encodeValue(T({ code: TypeCode.BOOL }), false))).toBe(false); + }); + + it('rejects SET (not implemented)', () => { + expect(() => + encodeValue(T({ code: TypeCode.SET, types: [T({ code: TypeCode.STRING })] }), new Set(), REG), + ).toThrow(/SET not implemented/); + }); +}); + +// Suppress unused-warning for the TYPE const helper by using it once. +const _checkTypeHelper: KrpcType = T({ code: TypeCode.STRING }); +void _checkTypeHelper; +// Also pin the protobufjs default import to confirm we still have it. +const _pb: typeof protobuf = protobuf; +void _pb; diff --git a/packages/krpc-client/tests/service-client.test.ts b/packages/krpc-client/tests/service-client.test.ts new file mode 100644 index 0000000..af7e8aa --- /dev/null +++ b/packages/krpc-client/tests/service-client.test.ts @@ -0,0 +1,383 @@ +/** + * End-to-end test of KrpcServices with a tiny mock kRPC server. + * + * We start a real TCP server that: + * 1. Accepts the RPC + stream handshakes + * 2. Handles a small whitelist of procedure calls by responding + * with hand-encoded values + * + * The test then drives a real KRPCClient + KrpcServices pair through + * the same wire format a real kRPC server uses, and verifies the + * decoded values match the hand-encoded responses. + * + * For the server side we use the same SocketReader-based pattern that + * the client uses, so the two sides share framing semantics. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as net from 'node:net'; +import { Buffer } from 'node:buffer'; +import { KRPC, encodeMessage } from '../src/schema.js'; +import { KRPCClient } from '../src/client.js'; +import { loadServices, KrpcServices } from '../src/service-client.js'; +import { encodeVarint, recvMessage, sendMessage } from '../src/connection.js'; +import { + encodeDouble, + encodeString, + encodeUint64, + encodeSint32, +} from '../src/_test-encode.js'; + +/** Mock services message that the server will hand back on GetServices. */ +const MOCK_SERVICES_RAW = { + services: [ + { + name: 'KRPC', + procedures: [ + { + name: 'GetStatus', + parameters: [], + returnType: { code: 203, service: '', name: '', types: [] }, + returnIsNullable: false, + }, + { + name: 'GetServices', + parameters: [], + returnType: { code: 204, service: '', name: '', types: [] }, + returnIsNullable: false, + }, + ], + classes: [], + enumerations: [], + }, + { + name: 'SpaceCenter', + procedures: [ + { + name: 'GetUT', + parameters: [], + returnType: { code: 1, service: '', name: '', types: [] }, + returnIsNullable: false, + }, + { + name: 'GetActiveVessel', + parameters: [], + returnType: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] }, + returnIsNullable: false, + }, + { + name: 'GetBodies', + parameters: [], + returnType: { + code: 301, + service: '', + name: '', + types: [{ code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }], + }, + returnIsNullable: false, + }, + { + name: 'CelestialBody.GetName', + parameters: [ + { + name: 'self', + type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }, + nullable: false, + }, + ], + returnType: { code: 8, service: '', name: '', types: [] }, + returnIsNullable: false, + }, + { + name: 'Vessel.GetName', + parameters: [ + { + name: 'self', + type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] }, + nullable: false, + }, + ], + returnType: { code: 8, service: '', name: '', types: [] }, + returnIsNullable: false, + }, + { + name: 'Vessel.GetType', + parameters: [ + { + name: 'self', + type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] }, + nullable: false, + }, + ], + returnType: { code: 101, service: 'SpaceCenter', name: 'VesselType', types: [] }, + returnIsNullable: false, + }, + ], + classes: [{ name: 'CelestialBody' }, { name: 'Vessel' }, { name: 'Orbit' }], + enumerations: [ + { + name: 'VesselType', + values: [ + { name: 'Ship', value: 0 }, + { name: 'Probe', value: 3 }, + ], + }, + ], + }, + ], +}; + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.unref(); + srv.on('error', reject); + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address(); + if (addr && typeof addr === 'object') { + const p = addr.port; + srv.close(() => resolve(p)); + } else { + srv.close(() => reject(new Error('no addr'))); + } + }); + }); +} + +function replyWithValue(socket: net.Socket, value: Uint8Array): void { + const resultMsg = KRPC.ProcedureResult.create({ + value: Buffer.from(value), + }); + const respMsg = KRPC.Response.create({ results: [resultMsg] }); + const payload = Buffer.from(KRPC.Response.encode(respMsg).finish()); + sendMessage(socket, KRPC.Response, { results: [resultMsg] }); + void payload; +} + +function replyWithError(socket: net.Socket, name: string, description: string): void { + const errMsg = KRPC.Error.create({ service: 'Test', name, description }); + const resultMsg = KRPC.ProcedureResult.create({ error: errMsg, value: Buffer.from([]) }); + sendMessage(socket, KRPC.Response, { results: [resultMsg] }); +} + +interface MockServer { + rpcPort: number; + streamPort: number; + close: () => Promise; + stub: ( + service: string, + procedure: string, + handler: (args: Uint8Array[]) => Uint8Array, + ) => void; + /** Counts the number of calls received for (service, procedure). */ + callCount: (service: string, procedure: string) => number; + /** Captured arguments of the last call to (service, procedure). */ + lastArgs: (service: string, procedure: string) => Uint8Array[]; +} + +async function startMockServer(): Promise { + const rpcPort = await freePort(); + const streamPort = await freePort(); + const stubs = new Map Uint8Array>(); + const counts = new Map(); + const lastArgsMap = new Map(); + + const stub = (svc: string, proc: string, handler: (args: Uint8Array[]) => Uint8Array) => { + stubs.set(`${svc}.${proc}`, handler); + }; + + // Default stubs + stub('KRPC', 'GetServices', () => { + const servicesMsg = KRPC.Services.create(MOCK_SERVICES_RAW); + return new Uint8Array(KRPC.Services.encode(servicesMsg).finish()); + }); + stub('KRPC', 'GetStatus', () => { + const statusMsg = KRPC.Status.create({ version: 'test' }); + return new Uint8Array(KRPC.Status.encode(statusMsg).finish()); + }); + stub('SpaceCenter', 'GetUT', () => encodeDouble(4_700_000.5)); + + // ── RPC server ───────────────────────────────────────────────────── + const rpcServer = net.createServer((socket) => { + void (async () => { + try { + // 1. Handshake + const req = await recvMessage<{ type: number; clientName: string }>( + socket, + KRPC.ConnectionRequest, + ); + if (req.type !== 0) { + socket.destroy(); + return; + } + sendMessage(socket, KRPC.ConnectionResponse, { + status: 0, + message: '', + clientIdentifier: Buffer.from('test-client'), + }); + + // 2. Procedure loop + while (!socket.destroyed) { + let callReq: { + calls: { service: string; procedure: string; arguments?: { value: Uint8Array }[] }[]; + }; + try { + callReq = await recvMessage(socket, KRPC.Request); + } catch { + return; + } + const call = callReq.calls[0]; + if (!call) { + replyWithError(socket, 'Malformed', 'no call'); + continue; + } + const key = `${call.service}.${call.procedure}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + const args = (call.arguments ?? []).map((a) => new Uint8Array(a.value)); + lastArgsMap.set(key, args); + const handler = stubs.get(key); + if (!handler) { + replyWithError(socket, 'UnknownProcedure', `${key} not stubbed`); + continue; + } + try { + const value = handler(args); + replyWithValue(socket, value); + } catch (e) { + replyWithError(socket, 'HandlerError', String(e)); + } + } + } catch { + if (!socket.destroyed) socket.destroy(); + } + })(); + }); + await new Promise((resolve) => rpcServer.listen(rpcPort, '127.0.0.1', resolve)); + + // ── Stream server (handshake only, then idle) ───────────────────── + const streamServer = net.createServer((socket) => { + void (async () => { + try { + const req = await recvMessage<{ type: number; clientIdentifier: Uint8Array }>( + socket, + KRPC.ConnectionRequest, + ); + if (req.type !== 1) { + socket.destroy(); + return; + } + sendMessage(socket, KRPC.ConnectionResponse, { status: 0, message: '' }); + // Keep alive + await new Promise(() => undefined); + } catch { + socket.destroy(); + } + })(); + }); + await new Promise((resolve) => streamServer.listen(streamPort, '127.0.0.1', resolve)); + + return { + rpcPort, + streamPort, + stub, + callCount: (svc, proc) => counts.get(`${svc}.${proc}`) ?? 0, + lastArgs: (svc, proc) => lastArgsMap.get(`${svc}.${proc}`) ?? [], + close: () => + new Promise((resolve) => { + rpcServer.close(() => { + streamServer.close(() => resolve()); + }); + }), + }; +} + +describe('KrpcServices against a mock kRPC server', () => { + let server: MockServer; + let client: KRPCClient; + let services: KrpcServices; + + beforeAll(async () => { + server = await startMockServer(); + client = new KRPCClient({ + host: '127.0.0.1', + rpcPort: server.rpcPort, + streamPort: server.streamPort, + clientName: 'test', + }); + await client.connect(); + const loaded = await loadServices(client); + services = loaded.services; + }, 10_000); + + afterAll(async () => { + await client.close(); + await server.close(); + }); + + it('lists services', () => { + const names = services.getCache().serviceNames(); + expect(names).toContain('KRPC'); + expect(names).toContain('SpaceCenter'); + }); + + it('looks up the GetUT procedure', () => { + const r = services.getCache().lookup('SpaceCenter', 'GetUT'); + expect(r.found).toBe(true); + }); + + it('invokes SpaceCenter.GetUT and decodes as double', async () => { + const ut = await services.invoke('SpaceCenter', 'GetUT'); + expect(ut).toBe(4_700_000.5); + }); + + it('invokes SpaceCenter.GetBodies and decodes a list of class ids', async () => { + server.stub('SpaceCenter', 'GetBodies', () => { + const items = [encodeUint64(1n), encodeUint64(2n), encodeUint64(3n)]; + const listMsg = KRPC.List.create({ items }); + return new Uint8Array(KRPC.List.encode(listMsg).finish()); + }); + const ids = await services.invoke('SpaceCenter', 'GetBodies'); + expect(ids).toEqual([1n, 2n, 3n]); + }); + + it('invokes a class method and decodes the string return', async () => { + server.stub('SpaceCenter', 'CelestialBody.GetName', () => encodeString('Kerbin')); + const name = await services.invoke('SpaceCenter', 'CelestialBody.GetName', 42n); + expect(name).toBe('Kerbin'); + }); + + it('rejects an unknown procedure', async () => { + await expect( + services.invoke('SpaceCenter', 'GetNothing'), + ).rejects.toThrow(/procedure not found/); + }); + + it('rejects a wrong number of arguments', async () => { + await expect( + services.invoke('SpaceCenter', 'GetBodies', 1n, 2n), + ).rejects.toThrow(/wrong number of arguments/); + }); + + it('decodes an enum return value', async () => { + server.stub('SpaceCenter', 'Vessel.GetType', () => encodeSint32(3)); + const t = await services.invoke('SpaceCenter', 'Vessel.GetType', 7n); + expect(t).toBe(3); + }); + + it('passes BigInt class ids through to class methods', async () => { + let receivedId: bigint | null = null; + server.stub('SpaceCenter', 'CelestialBody.GetName', (args) => { + const argBytes = args[0] ?? new Uint8Array(); + let v = 0n; + let shift = 0n; + for (const b of argBytes) { + v |= BigInt(b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7n; + } + receivedId = v; + return encodeString('Mun'); + }); + await services.invoke('SpaceCenter', 'CelestialBody.GetName', 99n); + expect(receivedId).toBe(99n); + }); +}); diff --git a/packages/krpc-client/tests/services.test.ts b/packages/krpc-client/tests/services.test.ts new file mode 100644 index 0000000..cce94be --- /dev/null +++ b/packages/krpc-client/tests/services.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect } from 'vitest'; +import { ServiceCache, type RawServicesMessage } from '../src/services.js'; + +/** + * A small hand-crafted services message that mirrors the shape we'd + * get from KRPC.GetServices() on a real KSP install. Just enough + * services/procedures/enums to exercise the cache. + */ +const SAMPLE_RAW: RawServicesMessage = { + services: [ + { + name: 'SpaceCenter', + procedures: [ + { + name: 'GetUT', + parameters: [], + returnType: { code: 1, service: '', name: '', types: [] }, // DOUBLE + returnIsNullable: false, + }, + { + name: 'GetBodies', + parameters: [], + returnType: { + code: 301, // LIST + service: '', + name: '', + types: [{ code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }], + }, + returnIsNullable: false, + }, + { + name: 'CelestialBody.GetName', + parameters: [ + { + name: 'self', + type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }, + nullable: false, + }, + ], + returnType: { code: 8, service: '', name: '', types: [] }, // STRING + returnIsNullable: false, + }, + { + name: 'CelestialBody.GetParent', + parameters: [ + { + name: 'self', + type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }, + nullable: false, + }, + ], + // Nullable CelestialBody (i.e. Sun has no parent). + returnType: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }, + returnIsNullable: true, + }, + ], + classes: [ + { name: 'CelestialBody' }, + { name: 'Vessel' }, + { name: 'Orbit' }, + ], + enumerations: [ + { + name: 'VesselType', + values: [ + { name: 'Ship', value: 0 }, + { name: 'Station', value: 1 }, + { name: 'Probe', value: 3 }, + { name: 'Debris', value: 8 }, + ], + }, + { + name: 'VesselSituation', + values: [ + { name: 'PreLaunch', value: 0 }, + { name: 'Orbiting', value: 1 }, + { name: 'Escaping', value: 2 }, + { name: 'Landed', value: 4 }, + { name: 'Splashed', value: 5 }, + ], + }, + ], + }, + { + name: 'KRPC', + procedures: [ + { + name: 'GetStatus', + parameters: [], + returnType: { code: 203, service: '', name: '', types: [] }, // STATUS + returnIsNullable: false, + }, + ], + classes: [], + enumerations: [], + }, + ], +}; + +describe('ServiceCache', () => { + it('builds from a raw services message', () => { + const cache = new ServiceCache(SAMPLE_RAW); + expect(cache.procedureCount()).toBe(5); + expect(cache.serviceNames()).toEqual(['KRPC', 'SpaceCenter']); + }); + + it('looks up a top-level procedure', () => { + const cache = new ServiceCache(SAMPLE_RAW); + const r = cache.lookup('SpaceCenter', 'GetUT'); + expect(r.found).toBe(true); + if (!r.found) throw new Error('unreachable'); + expect(r.info.service).toBe('SpaceCenter'); + expect(r.info.name).toBe('GetUT'); + expect(r.info.returnType.code).toBe(1); // DOUBLE + expect(r.info.returnIsNullable).toBe(false); + expect(r.info.parameters).toHaveLength(0); + }); + + it('looks up a class-prefixed procedure', () => { + const cache = new ServiceCache(SAMPLE_RAW); + const r = cache.lookup('SpaceCenter', 'CelestialBody.GetName'); + expect(r.found).toBe(true); + if (!r.found) throw new Error('unreachable'); + expect(r.info.parameters).toHaveLength(1); + expect(r.info.parameters[0]?.name).toBe('self'); + expect(r.info.parameters[0]?.type.code).toBe(100); // CLASS + expect(r.info.parameters[0]?.type.name).toBe('CelestialBody'); + expect(r.info.returnType.code).toBe(8); // STRING + }); + + it('returns not-found for an unknown procedure', () => { + const cache = new ServiceCache(SAMPLE_RAW); + expect(cache.lookup('SpaceCenter', 'GetNothing').found).toBe(false); + expect(cache.lookup('Nope', 'X').found).toBe(false); + }); + + it('returns not-found for an unknown service', () => { + const cache = new ServiceCache(SAMPLE_RAW); + expect(cache.lookup('KerbalAlarmClock', 'GetAlarms').found).toBe(false); + }); + + it('records returnIsNullable for nullable CLASS returns', () => { + const cache = new ServiceCache(SAMPLE_RAW); + const r = cache.lookup('SpaceCenter', 'CelestialBody.GetParent'); + expect(r.found).toBe(true); + if (!r.found) throw new Error('unreachable'); + expect(r.info.returnIsNullable).toBe(true); + expect(r.info.returnType.code).toBe(100); + expect(r.info.returnType.name).toBe('CelestialBody'); + }); + + it('resolves enum values by name', () => { + const cache = new ServiceCache(SAMPLE_RAW); + expect(cache.getEnumValue('SpaceCenter', 'VesselType', 'Ship')).toBe(0); + expect(cache.getEnumValue('SpaceCenter', 'VesselType', 'Station')).toBe(1); + expect(cache.getEnumValue('SpaceCenter', 'VesselType', 'Probe')).toBe(3); + expect(cache.getEnumValue('SpaceCenter', 'VesselSituation', 'Orbiting')).toBe(1); + }); + + it('throws for unknown enum or enum value', () => { + const cache = new ServiceCache(SAMPLE_RAW); + expect(() => cache.getEnumValue('SpaceCenter', 'NoSuch', 'X')).toThrow(/unknown enum/); + expect(() => cache.getEnumValue('SpaceCenter', 'VesselType', 'Hovercraft')).toThrow( + /unknown value/, + ); + }); + + it('resolves enum value names by code', () => { + const cache = new ServiceCache(SAMPLE_RAW); + expect(cache.getEnumName('SpaceCenter', 'VesselType', 0)).toBe('Ship'); + expect(cache.getEnumName('SpaceCenter', 'VesselType', 3)).toBe('Probe'); + expect(cache.getEnumName('SpaceCenter', 'VesselType', 999)).toBeNull(); + }); + + it('lists enum value names', () => { + const cache = new ServiceCache(SAMPLE_RAW); + const names = cache.getEnumNames('SpaceCenter', 'VesselType'); + expect(names).toEqual(['Debris', 'Probe', 'Ship', 'Station']); // sorted + }); + + it('lists procedures in a service', () => { + const cache = new ServiceCache(SAMPLE_RAW); + const procs = cache.proceduresInService('SpaceCenter'); + expect(procs).toContain('SpaceCenter.GetUT'); + expect(procs).toContain('SpaceCenter.CelestialBody.GetName'); + expect(procs).toContain('SpaceCenter.CelestialBody.GetParent'); + }); + + it('preserves nested list type info', () => { + const cache = new ServiceCache(SAMPLE_RAW); + const r = cache.lookup('SpaceCenter', 'GetBodies'); + expect(r.found).toBe(true); + if (!r.found) throw new Error('unreachable'); + expect(r.info.returnType.code).toBe(301); // LIST + expect(r.info.returnType.types).toHaveLength(1); + expect(r.info.returnType.types[0]?.code).toBe(100); + expect(r.info.returnType.types[0]?.name).toBe('CelestialBody'); + }); +});