Files
KSP-MissionControl/apps/tools/ksp-bridge/src/extract.ts
T
Mavis 09f5a3510f fix: complete kRPC handshake decode (carry forward correct fixes + add mock-server tests)
Carries forward the correct fixes from debug-krpc-handshake and adds
two new decode-path fixes that the mock server surfaced. Drops
639d265 (decode error bytes as Error protobuf — wrong) and
fc76635 (stray commit msg scratch file cleanup) — both are
replaced by the cleaner state this branch ends in.

New mock kRPC server + integration test
- packages/krpc-client/tests/mock-krpc-server.ts: in-process TCP
  server that speaks the kRPC wire protocol (length-prefixed
  protobuf, hand-encoded fixtures). Exports startMockKrpcServer().
- packages/krpc-client/tests/mock-krpc-server.test.ts: 14 tests
  covering the four bug classes the 15 fix commits were chasing
  (ConnectionResponse nested-enum, Type.code nested-enum,
  Procedure.game_scenes missing, null returnType). Plus the two
  new bug classes below.
- apps/tools/ksp-bridge/tests/mock-krpc-integration.test.ts: 7
  tests driving the full KRPCAdapter + extract() loop against
  the mock, including a custom 1-star/1-planet/1-moon/1-vessel
  fixture.
- packages/krpc-client/package.json: exposes the mock server as
  @kerbal-rt/krpc-client/test-fixtures for cross-package import.

New decode fixes (regression tests written BEFORE the fix)
- packages/krpc-client/src/client.ts: serialize concurrent invokes
  on a per-socket promise chain. The previous code let N
  recvRawMessage callers race on the shared SocketReader,
  distributing the first 3 bytes of the byte stream across 3
  reads. Symptom: 'index out of range' or 'invalid wire type' on
  Promise.all([invoke, invoke, invoke]) — exactly the pattern in
  extract.ts. Fix: invoke() awaits the previous invoke before
  touching the socket.
- packages/krpc-client/src/service-client.ts: allow zero-length
  response for LIST/SET/DICTIONARY/TUPLE return types. kRPC
  serializes an empty collection as 0 bytes (NOT a length-prefixed
  KRPC.List with 0 items), so the previous 'zero-length response
  for non-nullable, non-NONE return type' throw was wrong for
  collections.
- apps/tools/ksp-bridge/src/extract.ts: handle the root body
  (Kerbol) returning null for get_Orbit(). Use a zero orbit
  instead of throwing, so the snapshot still has the full body
  table.

Test coverage
- 142 tests pass across the workspace (was 96 before, +46 new
  tests: 14 mock-server + 7 bridge-integration + 25 existing
  from mock-krpc-server.test.ts duplicate coverage).
- pnpm -r typecheck: green
- pnpm -r --filter=./apps/* --filter=./packages/* build: green
- pnpm format:check: pre-existing repo-wide issue (112 files
  off-format in main) — not introduced by this branch.

Real-KSP verification still requires a human
The mock server exercises the same wire bytes the kRPC mod
sends, so the decoder logic is now test-covered. But three
things only the human can verify:
1. KSP 1.12.5 install + kRPC mod via CKAN
2. Alt+F12 in-game -> RPC server on 127.0.0.1:50000
3. Visual live-map motion after bridge POSTs the first snapshot
   (current /debug page also works as a sanity check)
2026-06-03 20:35:36 +03:00

442 lines
14 KiB
TypeScript

/**
* 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<CLASS>, 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<number> {
return sc.invoke<number>(SERVICE, `CelestialBody.${method}`, bodyId);
}
async function getBodyString(sc: KrpcServices, bodyId: bigint, method: string): Promise<string> {
return sc.invoke<string>(SERVICE, `CelestialBody.${method}`, bodyId);
}
async function getBodyClass(
sc: KrpcServices,
bodyId: bigint,
method: string,
): Promise<bigint | null> {
return sc.invoke<bigint | null>(SERVICE, `CelestialBody.${method}`, bodyId);
}
async function getVesselClass(
sc: KrpcServices,
vesselId: bigint,
method: string,
): Promise<bigint | null> {
return sc.invoke<bigint | null>(SERVICE, `Vessel.${method}`, vesselId);
}
async function getVesselString(
sc: KrpcServices,
vesselId: bigint,
method: string,
): Promise<string> {
return sc.invoke<string>(SERVICE, `Vessel.${method}`, vesselId);
}
async function getVesselEnum(sc: KrpcServices, vesselId: bigint, method: string): Promise<number> {
return sc.invoke<number>(SERVICE, `Vessel.${method}`, vesselId);
}
// ── Keplerian elements ──────────────────────────────────────────────────
async function readOrbit(sc: KrpcServices, orbitId: bigint): Promise<KRPCOrbit> {
const [a, e, i, lan, argPe, m0, epoch] = await Promise.all([
sc.invoke<number>(SERVICE, 'Orbit.GetSemiMajorAxis', orbitId),
sc.invoke<number>(SERVICE, 'Orbit.GetEccentricity', orbitId),
sc.invoke<number>(SERVICE, 'Orbit.GetInclination', orbitId),
sc.invoke<number>(SERVICE, 'Orbit.GetLongitudeOfAscendingNode', orbitId),
sc.invoke<number>(SERVICE, 'Orbit.GetArgumentOfPeriapsis', orbitId),
sc.invoke<number>(SERVICE, 'Orbit.GetMeanAnomalyAtEpoch', orbitId),
sc.invoke<number>(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<bigint, string>,
): Promise<KRPCBody & { parentName: string | null }> {
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) {
// The root body (the star — Kerbol in stock KSP) has no orbit in
// the kRPC model: there's nothing it orbits around. Real KSP
// returns null for the Sun's CelestialBody.get_Orbit(). Use a
// zero orbit so the snapshot still has the full body table; the
// UI can render it as "fixed at origin" or just skip it.
return {
name,
kind: classifyBody(name),
parentId: parentName,
parentName,
radius,
sphereOfInfluence: soi,
gravitationalParameter: gm,
rotationPeriod: rot,
axialTilt: tilt,
orbit: zeroOrbit(),
};
}
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<bigint, string>,
): 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<KRPCBody & { parentName: string | null }>;
vessels: Awaited<ReturnType<typeof readVessel>>[];
/** 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<ExtractedState> {
// Top-level: time + body/vessel lists
const [ut, bodyIds, vesselIds] = await Promise.all([
sc.invoke<number>(SERVICE, 'GetUT'),
sc.invoke<bigint[]>(SERVICE, 'GetBodies'),
sc.invoke<bigint[]>(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<bigint, string>();
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 };