09f5a3510f
Carries forward the correct fixes from debug-krpc-handshake and adds two new decode-path fixes that the mock server surfaced. Drops639d265(decode error bytes as Error protobuf — wrong) andfc76635(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)
768 lines
27 KiB
TypeScript
768 lines
27 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|