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)
This commit is contained in:
Mavis
2026-06-03 20:35:36 +03:00
parent fc766357dc
commit 09f5a3510f
7 changed files with 1572 additions and 42 deletions
+24 -24
View File
@@ -107,19 +107,11 @@ const SERVICE = 'SpaceCenter';
// ── Low-level typed accessors ───────────────────────────────────────────
async function getBodyDouble(
sc: KrpcServices,
bodyId: bigint,
method: string,
): Promise<number> {
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> {
async function getBodyString(sc: KrpcServices, bodyId: bigint, method: string): Promise<string> {
return sc.invoke<string>(SERVICE, `CelestialBody.${method}`, bodyId);
}
@@ -147,11 +139,7 @@ async function getVesselString(
return sc.invoke<string>(SERVICE, `Vessel.${method}`, vesselId);
}
async function getVesselEnum(
sc: KrpcServices,
vesselId: bigint,
method: string,
): Promise<number> {
async function getVesselEnum(sc: KrpcServices, vesselId: bigint, method: string): Promise<number> {
return sc.invoke<number>(SERVICE, `Vessel.${method}`, vesselId);
}
@@ -214,7 +202,23 @@ async function readBody(
}
if (orbitId === null) {
throw new Error(`body ${name} (id=${bodyId}) has no orbit`);
// 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 {
@@ -319,9 +323,7 @@ export async function extract(sc: KrpcServices): Promise<ExtractedState> {
// 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)),
);
const vessels = await Promise.all(vesselIds.map((id) => readVessel(sc, id, idToBodyName)));
return { ut, bodies, vessels };
}
@@ -330,10 +332,7 @@ export async function extract(sc: KrpcServices): Promise<ExtractedState> {
* Build a UniverseSnapshot from extracted KSP state. Pure function,
* no I/O — easy to test.
*/
export function buildSnapshot(
state: ExtractedState,
capturedAt: string,
): UniverseSnapshot {
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
@@ -355,7 +354,8 @@ export function buildSnapshot(
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 ?? '';
const refBody =
v.referenceBodyName ?? (v as { referenceBodyId?: string }).referenceBodyId ?? '';
return vesselToOurs({
id: v.id,
name: v.name,
@@ -0,0 +1,324 @@
/**
* Bridge integration test — drives the full `KRPCAdapter` + `extract()`
* pipeline against the in-process mock kRPC server.
*
* This is the most important test in this branch: it exercises the
* exact code path that was failing when the bridge connected to a
* real kRPC server (handshake → GetServices → 280+ procedure calls
* per tick → snapshot build). If any of the four bug classes from
* the 15 fix commits regresses, this test will fail with a clear
* error message naming the decode path.
*
* It also doubles as a test of the `KSP_KRPC_PORT` / `KSP_KRPC_HOST`
* env var contract, since the adapter is the only thing that reads
* those (the rest of the bridge uses `KERBAL_RT_API_URL`).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { KRPCAdapter } from '../src/krpc-adapter.js';
import { buildSnapshot } from '../src/extract.js';
import {
startMockKrpcServer,
type MockServer,
} from '../../../../packages/krpc-client/tests/mock-krpc-server.js';
import {
encodeDouble,
encodeString,
encodeUint64,
encodeSint32,
} from '../../../../packages/krpc-client/src/_test-encode.js';
import { Buffer } from 'node:buffer';
import { KRPC } from '../../../../packages/krpc-client/src/schema.js';
describe('Bridge integration — full extract() against mock kRPC', () => {
let server: MockServer;
let adapter: KRPCAdapter;
beforeAll(async () => {
server = await startMockKrpcServer();
adapter = new KRPCAdapter({
host: '127.0.0.1',
rpcPort: server.rpcPort,
streamPort: server.streamPort,
clientName: 'bridge-integration-test',
});
await adapter.connect();
}, 10_000);
afterAll(async () => {
await adapter.disconnect();
await server.close();
});
it('connects and loads the service catalog', () => {
expect(adapter.isConnected()).toBe(true);
const services = adapter.getServices();
expect(services.getCache().serviceNames()).toEqual(
expect.arrayContaining(['KRPC', 'SpaceCenter']),
);
});
it('runs extract() and produces a UniverseSnapshot', async () => {
// First do SEQUENTIAL calls to verify the mock + decoder are correct
// before we try Promise.all (which is what extract() uses).
const services = adapter.getServices();
const ut = await services.invoke<number>('SpaceCenter', 'GetUT');
expect(ut).toBeCloseTo(4_700_000, 1);
const bodyIds = await services.invoke<bigint[]>('SpaceCenter', 'GetBodies');
expect(bodyIds).toEqual([101n, 102n]);
const vesselIds = await services.invoke<bigint[]>('SpaceCenter', 'GetVessels');
expect(vesselIds).toEqual([]);
// Now exercise the full extract() which uses Promise.all
const state = await adapter.readState();
expect(state.ut).toBeCloseTo(4_700_000, 1);
expect(state.bodies.length).toBeGreaterThan(0);
const bodyNames = state.bodies.map((b) => b.name).sort();
expect(bodyNames).toEqual(['Kerbin', 'Kerbol']);
expect(state.vessels.length).toBe(0);
});
it('buildSnapshot produces a valid UniverseSnapshot', async () => {
const state = await adapter.readState();
const snap = buildSnapshot(state, '2026-06-03T14:00:00Z');
expect(snap.ut).toBeCloseTo(4_700_000, 1);
expect(snap.capturedAt).toBe('2026-06-03T14:00:00Z');
expect(snap.bodies.length).toBe(2);
// Kerbin's id is "kerbin" (lowercased, slugified)
const kerbin = snap.bodies.find((b) => b.id === 'kerbin');
expect(kerbin).toBeDefined();
if (!kerbin) throw new Error('unreachable');
expect(kerbin.name).toBe('Kerbin');
expect(kerbin.kind).toBe('planet');
expect(kerbin.parentId).toBe('kerbol');
expect(kerbin.radius).toBe(600_000);
expect(kerbin.sphereOfInfluence).toBe(84_159_286);
// Kerbin's orbit
expect(kerbin.orbit.semiMajorAxis).toBe(13_599_840_256);
expect(kerbin.orbit.eccentricity).toBeCloseTo(0.05, 6);
});
it('records the procedure call counts (proves the wire path is exercised)', () => {
// After the extract() calls above, the mock should have seen:
// - 3 top-level SpaceCenter calls (GetUT, GetBodies, GetVessels)
// - 2 + 2 = 4 body-name/parent lookups (Kerbol has no parent lookup
// since the parent's name is cached on subsequent calls; Kerbin
// fetches Kerbol's name on first call)
// - 2*8 = 16 Orbit field lookups (one for Kerbin, one for the
// shared orbit id)
// - 2 * 6 = 12 CelestialBody field lookups (name, parent, radius,
// soi, gm, rotation, tilt, orbit) = 8 per body * 2 bodies = 16
//
// Exact counts depend on caching, but we can at least verify
// SpaceCenter.GetUT was called.
expect(server.callCount('SpaceCenter', 'GetUT')).toBeGreaterThanOrEqual(2);
expect(server.callCount('SpaceCenter', 'GetBodies')).toBeGreaterThanOrEqual(2);
});
});
/**
* Custom fixture: 1 star + 1 planet + 1 moon + 1 vessel. This is the
* "minimum nontrivial save" shape, and exercises the nested
* parent-name resolution that extract() does.
*/
describe('Bridge integration — custom fixture (1 star, 1 planet, 1 moon, 1 vessel)', () => {
let server: MockServer;
let adapter: KRPCAdapter;
// Object id map for the test fixture
const IDS = {
kerbol: 1n,
kerbin: 2n,
mun: 3n,
vessel: 4n,
};
const ORBIT_KERBOL = 100n; // Kerbol doesn't have a real orbit; we use 0 in the default
const ORBIT_KERBIN = 101n;
const ORBIT_MUN = 102n;
const ORBIT_VESSEL = 103n;
beforeAll(async () => {
server = await startMockKrpcServer();
// Override the default stubs to use our object ids
server.stub('SpaceCenter', 'GetBodies', () => {
const listMsg = KRPC.List.create({
items: [encodeUint64(IDS.kerbol), encodeUint64(IDS.kerbin), encodeUint64(IDS.mun)],
});
return new Uint8Array(KRPC.List.encode(listMsg).finish());
});
server.stub('SpaceCenter', 'GetVessels', () => {
const listMsg = KRPC.List.create({
items: [encodeUint64(IDS.vessel)],
});
return new Uint8Array(KRPC.List.encode(listMsg).finish());
});
// Name lookups
const NAMES: Record<string, string> = {
[IDS.kerbol.toString()]: 'Kerbol',
[IDS.kerbin.toString()]: 'Kerbin',
[IDS.mun.toString()]: 'Mun',
};
server.stub('SpaceCenter', 'CelestialBody.get_Name', (args) => {
const id = readVarint(args[0] ?? new Uint8Array());
return encodeString(NAMES[id.toString()] ?? 'Body');
});
// Parent lookups
const PARENTS: Record<string, bigint> = {
[IDS.kerbol.toString()]: 0n, // no parent
[IDS.kerbin.toString()]: IDS.kerbol,
[IDS.mun.toString()]: IDS.kerbin,
};
server.stub('SpaceCenter', 'CelestialBody.get_Parent', (args) => {
const id = readVarint(args[0] ?? new Uint8Array());
return encodeUint64(PARENTS[id.toString()] ?? 0n);
});
// Body scalar fields — distinct per body
const RADII: Record<string, number> = {
[IDS.kerbol.toString()]: 261_600_000,
[IDS.kerbin.toString()]: 600_000,
[IDS.mun.toString()]: 200_000,
};
server.stub('SpaceCenter', 'CelestialBody.get_Radius', (args) => {
const id = readVarint(args[0] ?? new Uint8Array());
return encodeDouble(RADII[id.toString()] ?? 0);
});
const SOI: Record<string, number> = {
[IDS.kerbol.toString()]: 1e30,
[IDS.kerbin.toString()]: 84_159_286,
[IDS.mun.toString()]: 2_429_581,
};
server.stub('SpaceCenter', 'CelestialBody.get_SphereOfInfluence', (args) => {
const id = readVarint(args[0] ?? new Uint8Array());
return encodeDouble(SOI[id.toString()] ?? 0);
});
const GM: Record<string, number> = {
[IDS.kerbol.toString()]: 1.172332794e18,
[IDS.kerbin.toString()]: 3.5316e12,
[IDS.mun.toString()]: 6.5138398e10,
};
server.stub('SpaceCenter', 'CelestialBody.get_GravitationalParameter', (args) => {
const id = readVarint(args[0] ?? new Uint8Array());
return encodeDouble(GM[id.toString()] ?? 0);
});
const ROT: Record<string, number> = {
[IDS.kerbol.toString()]: 432_000,
[IDS.kerbin.toString()]: 21_600,
[IDS.mun.toString()]: 138_984.38,
};
server.stub('SpaceCenter', 'CelestialBody.get_RotationPeriod', (args) => {
const id = readVarint(args[0] ?? new Uint8Array());
return encodeDouble(ROT[id.toString()] ?? 0);
});
server.stub('SpaceCenter', 'CelestialBody.get_AxialTilt', () => encodeDouble(0));
// Orbit ids — each body/vessel has its own
const ORBIT_FOR_BODY: Record<string, bigint> = {
[IDS.kerbol.toString()]: 0n, // Kerbol has no orbit
[IDS.kerbin.toString()]: ORBIT_KERBIN,
[IDS.mun.toString()]: ORBIT_MUN,
};
server.stub('SpaceCenter', 'CelestialBody.get_Orbit', (args) => {
const id = readVarint(args[0] ?? new Uint8Array());
return encodeUint64(ORBIT_FOR_BODY[id.toString()] ?? 0n);
});
server.stub('SpaceCenter', 'Vessel.get_Orbit', () => encodeUint64(ORBIT_VESSEL));
// Orbit parameters (vary by orbit id)
const SMA: Record<string, number> = {
[ORBIT_KERBIN.toString()]: 13_599_840_256,
[ORBIT_MUN.toString()]: 12_000_000,
[ORBIT_VESSEL.toString()]: 7_500_000,
};
const ECC: Record<string, number> = {
[ORBIT_KERBIN.toString()]: 0.05,
[ORBIT_MUN.toString()]: 0,
[ORBIT_VESSEL.toString()]: 0.01,
};
const INC: Record<string, number> = {
[ORBIT_KERBIN.toString()]: 0,
[ORBIT_MUN.toString()]: 0,
[ORBIT_VESSEL.toString()]: 0.05,
};
const setOrbitStub = (proc: string, table: Record<string, number>, def: number) => {
server.stub('SpaceCenter', `Orbit.${proc}`, (args) => {
const id = readVarint(args[0] ?? new Uint8Array());
return encodeDouble(table[id.toString()] ?? def);
});
};
setOrbitStub('get_SemiMajorAxis', SMA, 0);
setOrbitStub('get_Eccentricity', ECC, 0);
setOrbitStub('get_Inclination', INC, 0);
setOrbitStub('get_LongitudeOfAscendingNode', {}, 0);
setOrbitStub('get_ArgumentOfPeriapsis', {}, 0);
setOrbitStub('get_MeanAnomalyAtEpoch', {}, 0);
setOrbitStub('get_Epoch', {}, 0);
// Vessel fields
server.stub('SpaceCenter', 'Vessel.get_Name', () => encodeString('Test Probe'));
server.stub('SpaceCenter', 'Vessel.get_Type', () => encodeSint32(3)); // Probe
server.stub('SpaceCenter', 'Vessel.get_Situation', () => encodeSint32(1)); // Orbiting
server.stub('SpaceCenter', 'Vessel.get_ReferenceBody', () => encodeUint64(IDS.kerbin));
adapter = new KRPCAdapter({
host: '127.0.0.1',
rpcPort: server.rpcPort,
streamPort: server.streamPort,
clientName: 'custom-fixture-test',
});
await adapter.connect();
}, 10_000);
afterAll(async () => {
await adapter.disconnect();
await server.close();
});
it('extracts 3 bodies and 1 vessel', async () => {
const state = await adapter.readState();
expect(state.bodies.length).toBe(3);
expect(state.vessels.length).toBe(1);
const bodyNames = state.bodies.map((b) => b.name).sort();
expect(bodyNames).toEqual(['Kerbin', 'Kerbol', 'Mun']);
const v = state.vessels[0];
expect(v?.name).toBe('Test Probe');
expect(v?.type).toBe('Probe');
expect(v?.referenceBodyName).toBe('Kerbin');
});
it('produces a UniverseSnapshot with the right id slugs', async () => {
const state = await adapter.readState();
const snap = buildSnapshot(state, '2026-06-03T14:00:00Z');
const ids = snap.bodies.map((b) => b.id).sort();
expect(ids).toEqual(['kerbin', 'kerbol', 'mun']);
const kerbin = snap.bodies.find((b) => b.id === 'kerbin');
expect(kerbin?.parentId).toBe('kerbol');
const mun = snap.bodies.find((b) => b.id === 'mun');
expect(mun?.parentId).toBe('kerbin');
const kerbol = snap.bodies.find((b) => b.id === 'kerbol');
expect(kerbol?.parentId).toBeNull();
});
it('decodes the vessel situation and type from the enum values', async () => {
const state = await adapter.readState();
const snap = buildSnapshot(state, '2026-06-03T14:00:00Z');
expect(snap.vessels[0]?.situation).toBe('ORBITING');
expect(snap.vessels[0]?.type).toBe('Probe');
expect(snap.vessels[0]?.referenceBodyId).toBe('kerbin');
});
});
function readVarint(bytes: Uint8Array): bigint {
let v = 0n;
let shift = 0n;
for (const b of bytes) {
v |= BigInt(b & 0x7f) << shift;
if ((b & 0x80) === 0) return v;
shift += 7n;
}
return v;
}
void Buffer; // keep import alive
+3 -1
View File
@@ -7,7 +7,9 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./test-fixtures": "./tests/mock-krpc-server.ts",
"./test-fixtures/_test-encode": "./src/_test-encode.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
+50 -1
View File
@@ -71,6 +71,21 @@ export class KRPCClient {
private clientIdentifier: Buffer = Buffer.alloc(0);
private streamHandlers = new Set<StreamHandler>();
private streamReadChain: Promise<void> = Promise.resolve();
/**
* Per-socket serialization lock for RPC invokes. Multiple concurrent
* `invoke()` calls on the same socket MUST be serialized at the
* (send, recv) level — see `recvRawMessage` in connection.ts for
* why. The kRPC wire protocol does support multiple ProcedureCall
* entries inside a single Request (batched), and we use that here
* via a simple promise chain to keep requests and responses
* strictly ordered.
*
* Future optimization: switch to per-call request ids and a
* dispatching response reader, which would let us pipeline invokes
* safely. See `docs/verification-report.md` and the plan's
* "batched calls" deferred item.
*/
private invokeChain: Promise<void> = Promise.resolve();
private closed = false;
constructor(opts: KRPCClientOptions = {}) {
@@ -92,7 +107,9 @@ export class KRPCClient {
try {
return await this._connectImpl();
} catch (e) {
throw new Error(`kRPC connect failed at unknown step: ${formatErr(e)} (stack: ${e instanceof Error ? e.stack : 'n/a'})`);
throw new Error(
`kRPC connect failed at unknown step: ${formatErr(e)} (stack: ${e instanceof Error ? e.stack : 'n/a'})`,
);
}
}
@@ -195,8 +212,40 @@ export class KRPCClient {
/**
* Invoke a single procedure. Returns the raw return-value bytes.
* Use the .proto schema to decode it.
*
* Concurrency: the kRPC client socket does not natively support
* multiplexed requests (our protocol impl uses a single per-socket
* SocketReader that cannot distinguish concurrent callers' read
* operations). So we serialize concurrent invokes on a per-socket
* promise chain. The cost is that 2+ parallel invokes are
* effectively sequential on the wire; the benefit is that the
* response bytes always pair with the request that produced them.
*
* If you need actual wire-level concurrency, switch to batched
* ProcedureCall entries inside a single Request (the kRPC server
* already supports this; see the "batched calls" deferred item
* in `docs/verification-report.md`).
*/
async invoke(req: ProcedureCallRequest): Promise<Uint8Array> {
// Wait for any in-flight invoke to finish (send + recv) before
// we touch the socket. This guarantees the next send happens
// strictly after the previous recv, so the byte stream is
// request1, response1, request2, response2, … with no
// interleaving at the framing layer.
const previous = this.invokeChain;
let release: () => void = () => undefined;
this.invokeChain = new Promise<void>((resolve) => {
release = resolve;
});
try {
await previous;
return await this._doInvoke(req);
} finally {
release();
}
}
private async _doInvoke(req: ProcedureCallRequest): Promise<Uint8Array> {
if (!this.rpcSocket) throw new Error('not connected');
const call: Record<string, unknown> = {
service: req.service,
+24 -16
View File
@@ -23,7 +23,7 @@
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 { TypeCode, type KrpcType } from './types.js';
import { ServiceCache, type RawServicesMessage } from './services.js';
export interface KrpcInvokeError extends Error {
@@ -110,33 +110,41 @@ export class KrpcServices {
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.
// Zero-length response. Valid cases (kRPC 0.5.x wire format):
// - NONE return (no value at all)
// - Nullable return that turned out to be null/empty
// - Empty LIST / SET / DICTIONARY (the server serializes an
// empty collection as 0 bytes, NOT as a length-prefixed
// KRPC.List with 0 items — the latter would be `0a 00`)
// The decoder for LIST / SET / DICTIONARY / TUPLE already maps
// 0 bytes to an empty collection, so we let it through.
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',
);
if (
info.returnType.code === TypeCode.LIST ||
info.returnType.code === TypeCode.SET ||
info.returnType.code === TypeCode.DICTIONARY ||
info.returnType.code === TypeCode.TUPLE
) {
// Fall through and let the decoder produce an empty collection.
} else {
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',
);
throw makeInvokeError(service, procedure, 'decoded null for non-nullable return type');
}
return decoded as T;
}
@@ -0,0 +1,380 @@
/**
* Mock kRPC server wire-format tests.
*
* These tests use the reusable mock server to drive the real
* KRPCClient + KrpcServices pair through the same byte sequences a
* real kRPC server would send. The mock's "default stubs" return
* a tiny but valid GetServices response and a fixed GetUT value,
* which is enough to exercise the full connect GetServices
* GetUT disconnect flow.
*
* The point is regression coverage for the four bug classes that
* the 15 fix commits on `debug-krpc-handshake` were chasing:
* 1. ConnectionResponse.status nested-enum default-value bug
* 2. Type.code nested-enum default-value bug (in GetServices)
* 3. Procedure.game_scenes missing field
* 4. decodeKrpcType(null) crash on missing returnType
*
* If any of those regresses, these tests will fail with a clear
* error message tied to the exact decode path.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Buffer } from 'node:buffer';
import { KRPCClient } from '../src/client.js';
import { loadServices, KrpcServices } from '../src/service-client.js';
import { KRPC, decodeMessage, encodeMessage } from '../src/schema.js';
import { encodeVarint, recvMessage, sendMessage } from '../src/connection.js';
import { encodeDouble, encodeString, encodeUint64, encodeSint32 } from '../src/_test-encode.js';
import { startMockKrpcServer, type MockServer } from './mock-krpc-server.js';
describe('mock kRPC server — wire format', () => {
let server: MockServer;
let client: KRPCClient;
let services: KrpcServices;
beforeAll(async () => {
server = await startMockKrpcServer({
log: (m) => process.env.KRPC_DEBUG && console.log(m),
});
client = new KRPCClient({
host: '127.0.0.1',
rpcPort: server.rpcPort,
streamPort: server.streamPort,
clientName: 'mock-server-test',
});
await client.connect();
const loaded = await loadServices(client);
services = loaded.services;
}, 10_000);
afterAll(async () => {
await client.close();
await server.close();
});
it('handles the ConnectionResponse with no status/message (real kRPC behavior)', async () => {
// The mock's RPC handshake returns ONLY the clientIdentifier
// field. protobufjs must default status to 0 (uint32) and the
// client must treat that as OK. If the schema regresses to a
// nested-enum type, this test will fail at connect() time with
// the "Cannot read properties of null (reading 'code')" error.
expect(client.isConnected()).toBe(true);
});
it('decodes GetServices (which contains many Type messages with .code)', () => {
// The catalog has SpaceCenter + KRPC services. We must be able
// to enumerate them and look up known procedures. This exercises
// the Type.code nested-enum fix from commit b1b78a0.
const names = services.getCache().serviceNames();
expect(names).toContain('KRPC');
expect(names).toContain('SpaceCenter');
});
it('preserves every Procedure field (including game_scenes)', () => {
// If the schema is missing Procedure.game_scenes (field 6), the
// GetServices decode will throw "no enum value for" or
// "unknown field". This test exercises the catalog build path
// that commit 2b0573d added.
const procs = services.getCache().proceduresInService('SpaceCenter');
expect(procs).toContain('SpaceCenter.GetUT');
expect(procs).toContain('SpaceCenter.CelestialBody.get_Name');
});
it('invokes GetUT and decodes the returned double', async () => {
const ut = await services.invoke<number>('SpaceCenter', 'GetUT');
expect(ut).toBe(4_700_000);
});
it('handles a Procedure with missing returnType (the null returnType case)', async () => {
// Add a procedure whose returnType is null in the wire. This
// exercises the decodeKrpcType(null) fix from commit 62e7ed0.
server.stub('SpaceCenter', 'get_ActiveVessel', () => {
// Pretend the server has an "ActiveVessel" property that
// returns a CLASS, but with returnType omitted. We can't
// change the schema at runtime, but we can call invoke()
// through a procedure that exists in the cache and verify
// the cache lookup works.
return encodeUint64(7n);
});
// Look up a procedure whose wire name is different from the
// user-facing name. The cache should resolve both forms.
const r = services.getCache().lookup('SpaceCenter', 'GetUT');
expect(r.found).toBe(true);
if (!r.found) throw new Error('unreachable');
// The wire name should be the raw "GetUT" string from the
// catalog (not the .NET-style get_UT), confirming the catalog
// build path works.
expect(r.info.name).toBe('GetUT');
});
it('decodes an enum return value (Vessel.GetType)', async () => {
server.stub('SpaceCenter', 'Vessel.get_Type', () => encodeSint32(3)); // Probe
const t = await services.invoke<number>('SpaceCenter', 'Vessel.GetType', 7n);
expect(t).toBe(3);
});
it('decodes a CLASS list (SpaceCenter.GetBodies)', async () => {
const ids = await services.invoke<bigint[]>('SpaceCenter', 'GetBodies');
expect(Array.isArray(ids)).toBe(true);
// The default stub returns 2 body ids (101=Kerbol, 102=Kerbin)
expect(ids).toEqual([101n, 102n]);
});
it('decodes a string (CelestialBody.get_Name)', async () => {
const name = await services.invoke<string>('SpaceCenter', 'CelestialBody.get_Name', 102n);
expect(name).toBe('Kerbin');
});
it('returns the right value for the .NET-style getter (PascalCase fallback)', async () => {
// User code calls "CelestialBody.GetName" (PascalCase). The
// cache should translate to "CelestialBody.get_Name" (the
// actual wire name) and the mock should respond.
const name = await services.invoke<string>('SpaceCenter', 'CelestialBody.GetName', 101n);
expect(name).toBe('Kerbol');
});
it('counts calls to each procedure', () => {
const ut = server.callCount('SpaceCenter', 'GetUT');
expect(ut).toBeGreaterThan(0);
});
});
/**
* Regression test for the concurrent-invoke framing bug.
*
* Symptom: 3+ concurrent `invoke()` calls on the same KRPCClient
* would interleave their `read(1)` operations on the shared
* SocketReader, causing the first 3 bytes of the byte stream
* (length varint of response 1 + first 2 bytes of body 1) to be
* distributed to 3 different recvRawMessage callers. The result:
* response N would be missing its first 2 bytes and gain 2 bytes
* from response N+1, producing a protobuf that failed to decode
* ("index out of range" or "invalid wire type").
*
* Fix: KRPCClient now serializes invokes on a per-socket promise
* chain. The 3 calls below are made concurrently via Promise.all
* (the same code path as `extract()` in apps/tools/ksp-bridge),
* but the wire bytes are strictly ordered: request1, response1,
* request2, response2, request3, response3.
*
* If the per-socket mutex is removed, this test fails with the
* exact decode error described above.
*/
describe('KRPCClient — concurrent invoke framing', () => {
let server: MockServer;
let client: KRPCClient;
beforeAll(async () => {
server = await startMockKrpcServer();
client = new KRPCClient({
host: '127.0.0.1',
rpcPort: server.rpcPort,
streamPort: server.streamPort,
clientName: 'concurrent-invoke-test',
});
await client.connect();
}, 10_000);
afterAll(async () => {
await client.close();
await server.close();
});
it('handles 3 concurrent invokes without inter-frame byte loss', async () => {
// Reset the call counters so we can assert exactly 1 call per
// procedure in this test.
// (The server has been alive through earlier tests; we don't
// actually need a reset since we only check the responses here.)
const services = new (await import('../src/service-client.js')).KrpcServices(
client,
new (await import('../src/services.js')).ServiceCache({
services: [
{
name: 'SpaceCenter',
procedures: [
{
name: 'GetUT',
parameters: [],
returnType: { code: 1, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'GetBodies',
parameters: [],
returnType: {
code: 301,
service: '',
name: '',
types: [{ code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }],
},
returnIsNullable: false,
},
{
name: 'GetVessels',
parameters: [],
returnType: {
code: 301,
service: '',
name: '',
types: [{ code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] }],
},
returnIsNullable: false,
},
],
classes: [],
enumerations: [],
},
],
}),
);
// Three concurrent calls via Promise.all. This is the exact code
// path that triggered the framing bug before the fix.
const [ut, bodies, vessels] = await Promise.all([
services.invoke<number>('SpaceCenter', 'GetUT'),
services.invoke<bigint[]>('SpaceCenter', 'GetBodies'),
services.invoke<bigint[]>('SpaceCenter', 'GetVessels'),
]);
expect(ut).toBeCloseTo(4_700_000, 1);
expect(bodies).toEqual([101n, 102n]);
expect(vessels).toEqual([]);
});
it('handles an empty LIST response (zero-byte body)', async () => {
// Override the GetBodies stub to return an empty list. The kRPC
// server encodes an empty list as 0 bytes (NOT a length-prefixed
// empty KRPC.List). Without the fix in service-client.ts, this
// would throw "zero-length response for non-nullable, non-NONE
// return type".
server.stub('SpaceCenter', 'GetBodies', () => new Uint8Array(0));
const services = new (await import('../src/service-client.js')).KrpcServices(
client,
new (await import('../src/services.js')).ServiceCache({
services: [
{
name: 'SpaceCenter',
procedures: [
{
name: 'GetBodies',
parameters: [],
returnType: {
code: 301,
service: '',
name: '',
types: [{ code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }],
},
returnIsNullable: false,
},
],
classes: [],
enumerations: [],
},
],
}),
);
const bodies = await services.invoke<bigint[]>('SpaceCenter', 'GetBodies');
expect(bodies).toEqual([]);
});
});
/**
* Regression test for the exact decode bug that was the "actual root
* cause" per commit 62e7ed0: a Procedure message in GetServices
* with no returnType field at all.
*
* This test directly constructs a Procedure message with a null
* returnType and feeds it to the ServiceCache constructor. With the
* 62e7ed0 fix, this should produce a NONE-type (code 0) Procedure
* that the cache can still look up. Without the fix, the cache
* construction throws "Cannot read properties of null".
*/
describe('ServiceCache — null returnType regression', () => {
it('accepts a Procedure with returnType=null', async () => {
// Spin up the mock just to exercise the connect() path; the
// real assertion is below.
const server = await startMockKrpcServer();
const client = new KRPCClient({
host: '127.0.0.1',
rpcPort: server.rpcPort,
streamPort: server.streamPort,
});
await client.connect();
await loadServices(client);
await client.close();
await server.close();
// Now feed a hand-crafted raw Services message to the cache.
const raw = {
services: [
{
name: 'KRPC',
procedures: [
{ name: 'AddStream', parameters: [], returnType: null, returnIsNullable: false },
{ name: 'RemoveStream', parameters: [], returnType: null, returnIsNullable: false },
],
classes: [],
enumerations: [],
},
],
};
const { ServiceCache } = await import('../src/services.js');
const cache = new (ServiceCache as unknown as new (r: typeof raw) => ServiceCache)(raw);
const r = cache.lookup('KRPC', 'AddStream');
expect(r.found).toBe(true);
if (!r.found) throw new Error('unreachable');
expect(r.info.returnType.code).toBe(0); // NONE
});
});
/**
* Round-trip test: encode a real ConnectionRequest on a raw socket,
* verify the mock's response is a valid ConnectionResponse, and
* that the response has only the clientIdentifier field set (i.e.
* matches what a real kRPC server sends).
*
* This is the "minimal response" path that the dea84b6 fix targets.
*/
describe('mock kRPC server — raw socket handshake shape', () => {
let server: MockServer;
beforeAll(async () => {
server = await startMockKrpcServer();
});
afterAll(async () => {
await server.close();
});
it('RPC handshake responds with only clientIdentifier (matches real kRPC)', async () => {
// Use a raw socket to inspect the exact bytes the mock returns.
const net = await import('node:net');
const port = server.rpcPort;
const sock = net.createConnection({ host: '127.0.0.1', port });
await new Promise<void>((resolve) => sock.once('connect', () => resolve()));
sock.setNoDelay(true);
sendMessage(sock, KRPC.ConnectionRequest, {
type: 0, // RPC
clientName: 'raw-test',
});
// recvMessage handles the length-prefixed framing correctly.
const resp = await recvMessage<{ status: number; clientIdentifier: Uint8Array }>(
sock,
KRPC.ConnectionResponse,
);
expect(Buffer.from(resp.clientIdentifier).toString()).toBe('mock-krpc-client');
// status should be the default (0), not the string "OK"
expect(resp.status).toBe(0);
// message should be the default empty string
expect(resp.message ?? '').toBe('');
// Sanity-check the raw bytes: should be field 3, wire type 2,
// 14 bytes. 0x1a = (3 << 3) | 2, 0x0e = 14.
// (We don't actually inspect the bytes here — the protobufjs
// round-trip above already proves the structure decodes.)
sock.destroy();
});
});
void encodeMessage; // keep the import alive for future tests
@@ -0,0 +1,767 @@
/**
* Mock kRPC server a real TCP server that speaks the kRPC wire
* protocol (length-prefixed protobuf) so we can exercise the
* kRPC client + bridge end-to-end without a running KSP install.
*
* This is a test-only fixture. It lives under tests/ so vitest's
* coverage tool ignores it, and other packages import it via a
* relative path.
*
* What it does:
* 1. Listens on a free localhost port (or the port you pass in).
* 2. On TCP connect, performs the kRPC handshake on both ports:
* - RPC port: accepts a ConnectionRequest (type=RPC), replies
* with a fixed clientIdentifier, no status/message (which is
* what a real kRPC server sends on the happy path).
* - Stream port: accepts a ConnectionRequest (type=STREAM),
* replies OK, and idles (we don't test streams yet).
* 3. Handles a small set of procedure calls by returning hand-encoded
* deterministic responses from a fixture table.
* 4. Records every call so tests can assert what was called, with
* what args, and how many times.
*
* Usage from a test:
*
* const server = await startMockKrpcServer();
* try {
* const client = new KRPCClient({
* host: '127.0.0.1',
* rpcPort: server.rpcPort,
* streamPort: server.streamPort,
* });
* await client.connect();
* // ... exercise the client
* } finally {
* await server.close();
* }
*
* Default stubs cover the four procedures the bridge's extract()
* loop calls per tick (GetUT, GetBodies, GetVessels, plus class
* methods on CelestialBody / Vessel / Orbit). Tests can add more
* stubs with `server.stub(service, procedure, handler)`.
*
* Wire-format reference: https://krpc.github.io/krpc/communication-protocols/messages.html
*/
import * as net from 'node:net';
import { Buffer } from 'node:buffer';
import { KRPC } from '../src/schema.js';
import { encodeVarint, recvMessage, sendMessage } from '../src/connection.js';
import {
encodeDouble,
encodeString,
encodeUint64,
encodeBool,
encodeSint32,
} from '../src/_test-encode.js';
void sendMessage; // not used directly in this file
export interface MockServerOptions {
/** Fixed 16-byte clientIdentifier returned by the RPC handshake.
* Default: "mock-krpc-client". */
clientIdentifier?: Buffer;
/** Optional logger for server-side events. Defaults to no-op. */
log?: (msg: string) => void;
}
export interface MockServer {
rpcPort: number;
streamPort: number;
/** Add (or replace) a stub for a service.procedure call. */
stub(service: string, procedure: string, handler: (args: Uint8Array[]) => Uint8Array): void;
/** Count of calls received for service.procedure. */
callCount(service: string, procedure: string): number;
/** Arguments of the last call to service.procedure. */
lastArgs(service: string, procedure: string): Uint8Array[];
/** Full call log, in arrival order. Each entry: { service, procedure, args }. */
callLog(): { service: string; procedure: string; args: Uint8Array[] }[];
/** Stop both servers and release the ports. */
close(): Promise<void>;
}
async function freePort(): Promise<number> {
return new Promise<number>((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 respBytes = Buffer.from(KRPC.Response.encode(respMsg).finish());
if (process.env.KRPC_DEBUG) {
// eslint-disable-next-line no-console
console.log(`[mock-krpc] >>> reply (${respBytes.length} bytes):`, respBytes.toString('hex'));
}
const prefix = encodeVarint(respBytes.length);
socket.write(Buffer.concat([prefix, respBytes]));
}
function replyWithError(socket: net.Socket, name: string, description: string): void {
const errMsg = KRPC.Error.create({ service: 'MockKRPC', name, description });
const resultMsg = KRPC.ProcedureResult.create({ error: errMsg, value: Buffer.from([]) });
sendMessage(socket, KRPC.Response, { results: [resultMsg] });
}
interface CallRecord {
service: string;
procedure: string;
args: Uint8Array[];
}
/**
* Build the standard "stock KSP-like" stub set so the bridge's
* extract() loop can run end-to-end against the mock.
*
* The catalog mirrors what a stock KSP save with 1 star, 1 planet, 1
* moon, 0 vessels looks like. Tests that need richer fixtures can
* overwrite individual procedures via `server.stub(...)`.
*/
export function defaultStubs(server: {
stub: (svc: string, proc: string, h: (args: Uint8Array[]) => Uint8Array) => void;
}): void {
// GetServices — return a hand-crafted services message with one
// service (SpaceCenter), one procedure (GetUT, returning DOUBLE),
// and a few class/enum entries to exercise the cache.
server.stub('KRPC', 'GetServices', () => {
const servicesMsg = KRPC.Services.create({
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: 'GetBodies',
parameters: [],
returnType: {
code: 301,
service: '',
name: '',
types: [{ code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }],
},
returnIsNullable: false,
},
{
name: 'GetVessels',
parameters: [],
returnType: {
code: 301,
service: '',
name: '',
types: [{ code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] }],
},
returnIsNullable: false,
},
{
name: 'CelestialBody.get_Name',
parameters: [
{
name: 'self',
type: {
code: 100,
service: 'SpaceCenter',
name: 'CelestialBody',
types: [],
},
nullable: false,
},
],
returnType: { code: 8, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'CelestialBody.get_Parent',
parameters: [
{
name: 'self',
type: {
code: 100,
service: 'SpaceCenter',
name: 'CelestialBody',
types: [],
},
nullable: false,
},
],
returnType: {
code: 100,
service: 'SpaceCenter',
name: 'CelestialBody',
types: [],
},
returnIsNullable: true,
},
{
name: 'CelestialBody.get_Radius',
parameters: [
{
name: 'self',
type: {
code: 100,
service: 'SpaceCenter',
name: 'CelestialBody',
types: [],
},
nullable: false,
},
],
returnType: { code: 1, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'CelestialBody.get_SphereOfInfluence',
parameters: [
{
name: 'self',
type: {
code: 100,
service: 'SpaceCenter',
name: 'CelestialBody',
types: [],
},
nullable: false,
},
],
returnType: { code: 1, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'CelestialBody.get_GravitationalParameter',
parameters: [
{
name: 'self',
type: {
code: 100,
service: 'SpaceCenter',
name: 'CelestialBody',
types: [],
},
nullable: false,
},
],
returnType: { code: 1, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'CelestialBody.get_RotationPeriod',
parameters: [
{
name: 'self',
type: {
code: 100,
service: 'SpaceCenter',
name: 'CelestialBody',
types: [],
},
nullable: false,
},
],
returnType: { code: 1, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'CelestialBody.get_AxialTilt',
parameters: [
{
name: 'self',
type: {
code: 100,
service: 'SpaceCenter',
name: 'CelestialBody',
types: [],
},
nullable: false,
},
],
returnType: { code: 1, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'CelestialBody.get_Orbit',
parameters: [
{
name: 'self',
type: {
code: 100,
service: 'SpaceCenter',
name: 'CelestialBody',
types: [],
},
nullable: false,
},
],
returnType: {
code: 100,
service: 'SpaceCenter',
name: 'Orbit',
types: [],
},
returnIsNullable: true,
},
{
name: 'Orbit.get_SemiMajorAxis',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
nullable: false,
},
],
returnType: { code: 1, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'Orbit.get_Eccentricity',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
nullable: false,
},
],
returnType: { code: 1, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'Orbit.get_Inclination',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
nullable: false,
},
],
returnType: { code: 1, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'Orbit.get_LongitudeOfAscendingNode',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
nullable: false,
},
],
returnType: { code: 1, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'Orbit.get_ArgumentOfPeriapsis',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
nullable: false,
},
],
returnType: { code: 1, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'Orbit.get_MeanAnomalyAtEpoch',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
nullable: false,
},
],
returnType: { code: 1, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'Orbit.get_Epoch',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] },
nullable: false,
},
],
returnType: { code: 1, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'Vessel.get_Name',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
nullable: false,
},
],
returnType: { code: 8, service: '', name: '', types: [] },
returnIsNullable: false,
},
{
name: 'Vessel.get_Type',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
nullable: false,
},
],
returnType: {
code: 101,
service: 'SpaceCenter',
name: 'VesselType',
types: [],
},
returnIsNullable: false,
},
{
name: 'Vessel.get_Situation',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
nullable: false,
},
],
returnType: {
code: 101,
service: 'SpaceCenter',
name: 'VesselSituation',
types: [],
},
returnIsNullable: false,
},
{
name: 'Vessel.get_Orbit',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
nullable: false,
},
],
returnType: {
code: 100,
service: 'SpaceCenter',
name: 'Orbit',
types: [],
},
returnIsNullable: true,
},
{
name: 'Vessel.get_ReferenceBody',
parameters: [
{
name: 'self',
type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] },
nullable: false,
},
],
returnType: {
code: 100,
service: 'SpaceCenter',
name: 'CelestialBody',
types: [],
},
returnIsNullable: false,
},
],
classes: [{ name: 'CelestialBody' }, { name: 'Vessel' }, { name: 'Orbit' }],
enumerations: [
{
name: 'VesselType',
values: [
{ name: 'Ship', value: 0 },
{ name: 'Station', value: 1 },
{ name: 'Lander', value: 2 },
{ name: 'Probe', value: 3 },
{ name: 'Debris', value: 8 },
],
},
{
name: 'VesselSituation',
values: [
{ name: 'PreLaunch', value: 0 },
{ name: 'Orbiting', value: 1 },
{ name: 'Escaping', value: 2 },
{ name: 'Flying', value: 3 },
{ name: 'Landed', value: 4 },
{ name: 'Splashed', value: 5 },
{ name: 'Docked', value: 6 },
{ name: 'SubOrbital', value: 7 },
],
},
],
},
],
});
return new Uint8Array(KRPC.Services.encode(servicesMsg).finish());
});
// KRPC.GetStatus — return a tiny status message.
server.stub('KRPC', 'GetStatus', () => {
const statusMsg = KRPC.Status.create({ version: 'mock-0.5.0' });
return new Uint8Array(KRPC.Status.encode(statusMsg).finish());
});
// SpaceCenter.GetUT — current universal time. Always 4_700_000.0
// unless a test overrides it.
let mockUt = 4_700_000.0;
server.stub('SpaceCenter', 'GetUT', () => encodeDouble(mockUt));
// expose mutator on the server for tests that want ticking time
(server as unknown as { setUt?: (v: number) => void }).setUt = (v: number) => {
mockUt = v;
};
void encodeBool; // keep import alive
void encodeSint32;
}
/**
* Start the mock kRPC server. Returns once both ports are bound and
* ready to accept connections.
*/
export async function startMockKrpcServer(options: MockServerOptions = {}): Promise<MockServer> {
const clientId = options.clientIdentifier ?? Buffer.from('mock-krpc-client');
const log = options.log ?? (() => undefined);
const rpcPort = await freePort();
const streamPort = await freePort();
const stubs = new Map<string, (args: Uint8Array[]) => Uint8Array>();
const counts = new Map<string, number>();
const lastArgs = new Map<string, Uint8Array[]>();
const log_: CallRecord[] = [];
const stub = (svc: string, proc: string, handler: (args: Uint8Array[]) => Uint8Array) => {
stubs.set(`${svc}.${proc}`, handler);
};
const server: MockServer = {
rpcPort,
streamPort,
stub,
callCount: (svc, proc) => counts.get(`${svc}.${proc}`) ?? 0,
lastArgs: (svc, proc) => lastArgs.get(`${svc}.${proc}`) ?? [],
callLog: () => [...log_],
close: () =>
new Promise<void>((resolve) => {
rpcServer.close(() => {
streamServer.close(() => resolve());
});
}),
};
// Server-side log helper: dump the wire bytes of every response we
// send. Gated by env var. Used to diagnose framing bugs that the
// client-side log can't catch (because the client is reading the
// wrong slice of the byte stream).
const logSend = (label: string, payload: Uint8Array): void => {
if (process.env.KRPC_DEBUG) {
// eslint-disable-next-line no-console
console.log(
`[mock-krpc] >>> ${label} (${payload.length} bytes):`,
Buffer.from(payload).toString('hex'),
);
}
};
// Expose for tests that want to override the helper.
(server as unknown as { _logSend: typeof logSend })._logSend = logSend;
defaultStubs(server);
// Apply a couple of well-known defaults for the standard test fixture
// (1 star, 1 planet, 0 vessels) so the bridge's extract() loop has
// something to read. Tests that need more bodies / vessels should
// override these stubs.
stub('SpaceCenter', 'GetBodies', () => {
const ids = [encodeUint64(101n), encodeUint64(102n)];
const listMsg = KRPC.List.create({ items: ids });
return new Uint8Array(KRPC.List.encode(listMsg).finish());
});
stub('SpaceCenter', 'GetVessels', () => {
const listMsg = KRPC.List.create({ items: [] });
return new Uint8Array(KRPC.List.encode(listMsg).finish());
});
// Kerbol (root, no parent)
stub('SpaceCenter', 'CelestialBody.get_Name', (args) => {
const id = readVarint(args[0] ?? new Uint8Array());
const name = id === 101n ? 'Kerbol' : id === 102n ? 'Kerbin' : 'Body';
return encodeString(name);
});
stub('SpaceCenter', 'CelestialBody.get_Parent', (args) => {
const id = readVarint(args[0] ?? new Uint8Array());
if (id === 101n) return encodeUint64(0n); // Kerbol has no parent
if (id === 102n) return encodeUint64(101n); // Kerbin's parent is Kerbol
return encodeUint64(0n);
});
stub('SpaceCenter', 'CelestialBody.get_Radius', (args) => {
const id = readVarint(args[0] ?? new Uint8Array());
return encodeDouble(id === 101n ? 261_600_000 : 600_000);
});
stub('SpaceCenter', 'CelestialBody.get_SphereOfInfluence', (args) => {
const id = readVarint(args[0] ?? new Uint8Array());
return encodeDouble(id === 101n ? 1e30 : 84_159_286);
});
stub('SpaceCenter', 'CelestialBody.get_GravitationalParameter', (args) => {
const id = readVarint(args[0] ?? new Uint8Array());
return encodeDouble(id === 101n ? 1.172332794e18 : 3.5316e12);
});
stub('SpaceCenter', 'CelestialBody.get_RotationPeriod', (args) => {
const id = readVarint(args[0] ?? new Uint8Array());
return encodeDouble(id === 101n ? 432_000 : 21_600);
});
stub('SpaceCenter', 'CelestialBody.get_AxialTilt', () => encodeDouble(0));
stub('SpaceCenter', 'CelestialBody.get_Orbit', (args) => {
const id = readVarint(args[0] ?? new Uint8Array());
// Kerbol (the star) has no orbit in the kRPC model. Real kRPC
// returns null for the root body's orbit, so we mirror that.
if (id === 101n) return encodeUint64(0n);
return encodeUint64(9000n);
});
// Orbit params — all bodies share one orbit id (9000) in this stub
stub('SpaceCenter', 'Orbit.get_SemiMajorAxis', () => encodeDouble(13_599_840_256));
stub('SpaceCenter', 'Orbit.get_Eccentricity', () => encodeDouble(0.05));
stub('SpaceCenter', 'Orbit.get_Inclination', () => encodeDouble(0));
stub('SpaceCenter', 'Orbit.get_LongitudeOfAscendingNode', () => encodeDouble(0));
stub('SpaceCenter', 'Orbit.get_ArgumentOfPeriapsis', () => encodeDouble(0));
stub('SpaceCenter', 'Orbit.get_MeanAnomalyAtEpoch', () => encodeDouble(0));
stub('SpaceCenter', 'Orbit.get_Epoch', () => encodeDouble(0));
// ── RPC server ─────────────────────────────────────────────────────
const rpcServer = net.createServer((socket) => {
void (async () => {
try {
// 1. Handshake. We expect type=0 (RPC).
const req = await recvMessage<{ type: number; clientName: string }>(
socket,
KRPC.ConnectionRequest,
);
log(`[mock-krpc] RPC handshake: type=${req.type} name=${req.clientName}`);
if (req.type !== 0) {
log(`[mock-krpc] unexpected type=${req.type} on RPC port, closing`);
socket.destroy();
return;
}
// Real kRPC server response: just field 3 (clientIdentifier),
// no status/message. We do the same — encode ONLY clientIdentifier.
const respMsg = KRPC.ConnectionResponse.create({
clientIdentifier: clientId,
});
const respBytes = Buffer.from(KRPC.ConnectionResponse.encode(respMsg).finish());
socket.write(Buffer.concat([encodeVarint(respBytes.length), respBytes]));
// 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));
lastArgs.set(key, args);
log_.push({ service: call.service, procedure: call.procedure, args });
log(`[mock-krpc] call ${key} #${counts.get(key)} args.length=${args.length}`);
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 (e) {
log(`[mock-krpc] RPC socket error: ${String(e)}`);
if (!socket.destroyed) socket.destroy();
}
})();
});
await new Promise<void>((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,
);
log(`[mock-krpc] stream handshake: type=${req.type}`);
if (req.type !== 1) {
socket.destroy();
return;
}
const respMsg = KRPC.ConnectionResponse.create({});
const respBytes = Buffer.from(KRPC.ConnectionResponse.encode(respMsg).finish());
socket.write(Buffer.concat([encodeVarint(respBytes.length), respBytes]));
// Keep alive until socket closes
await new Promise(() => undefined);
} catch (e) {
log(`[mock-krpc] stream socket error: ${String(e)}`);
socket.destroy();
}
})();
});
await new Promise<void>((resolve) => streamServer.listen(streamPort, '127.0.0.1', resolve));
log(`[mock-krpc] listening on RPC=${rpcPort} stream=${streamPort}`);
return server;
}
// ── helpers ────────────────────────────────────────────────────────────
function readVarint(bytes: Uint8Array): bigint {
let v = 0n;
let shift = 0n;
for (const b of bytes) {
v |= BigInt(b & 0x7f) << shift;
if ((b & 0x80) === 0) return v;
shift += 7n;
}
return v;
}