aebee77843
Built the missing piece that connects the ksp-bridge to a real KSP instance via kRPC. This adds a typed service client on top of the existing KRPCClient, plus the SpaceCenter-specific extraction logic that pulls the universe state from a running KSP save. @kerbal-rt/krpc-client - types.ts — runtime representation of kRPC Type descriptors (TypeCode enum, KrpcType interface, decodeKrpcType, typeName) - decoder.ts — kRPC value codec: primitive decode/encode, class refs, enums, collections (LIST/SET/TUPLE/DICTIONARY), system messages (STATUS/SERVICES/STREAM/EVENT/PROCEDURE_CALL). 34 unit tests. - services.ts — ServiceCache built from KRPC.GetServices() response. Lookup by (service, procedure), enum value/name resolution. 12 tests. - service-client.ts — KrpcServices: high-level invoke-by-name client. loadServices() helper to connect + load catalog. 9 integration tests with a mock kRPC server. - schema.ts — added Set/Dictionary/Event/Expression types so the decoder can handle system messages without external .proto files. Also fixed a bug where 'STREAM' was being encoded as 0 due to protobufjs's nested-enum string lookup. ksp-bridge - extract.ts — the actual SpaceCenter calls. ~280 procedure calls per poll for a typical KSP save (UT, bodies, vessels, then per-body and per-vessel class methods in parallel). Build the UniverseSnapshot. - krpc-adapter.ts — rewrote to use KrpcServices (typed) instead of the stub extract function. Supports an optional injected services for testing. - bridge.ts — uses the new ExtractedState type and buildSnapshot. - index.ts — connects to kRPC; falls back to mock mode if no server. - convert.ts — backward-compat shim, re-exports from extract.ts. The ksp-bridge can now talk to a real KSP install. We do NOT need the kRPC mod's .proto files on disk — the server's GetServices() response is the source of truth for type info. Documented the full list of procedures we call, the kRPC value encoding, and the new architecture in ksp/README.md. Tests: 119 total, all green. Typecheck and build clean across all 11 projects. Bonus: fixed an integer-overflow bug in the krpc-client connect() handshake (was passing 'RPC'/'STREAM' strings to protobufjs; its nested-enum string lookup silently encodes as 0, which made the stream handshake send the wrong type. Switched to numeric codes.)
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import protobuf from 'protobufjs';
|
|
import {
|
|
decodeValue,
|
|
encodeValue,
|
|
decodeDouble,
|
|
decodeFloat,
|
|
decodeSint32,
|
|
decodeUint32,
|
|
decodeUint64,
|
|
decodeBool,
|
|
decodeString,
|
|
decodeBytes,
|
|
decodeList,
|
|
decodeTuple,
|
|
decodeDictionary,
|
|
decodeKrpcList,
|
|
} from '../src/decoder.js';
|
|
import { TypeCode, type KrpcType } from '../src/types.js';
|
|
import { MESSAGE_TYPES, KRPC } from '../src/schema.js';
|
|
|
|
const T = (t: Partial<KrpcType>): KrpcType => ({
|
|
code: t.code ?? 0,
|
|
service: t.service ?? '',
|
|
name: t.name ?? '',
|
|
types: t.types ?? [],
|
|
});
|
|
|
|
// We need a small fake "TYPE" registry for the tests so the decoder
|
|
// can find List / Tuple / Dictionary / Status by name. We can use the
|
|
// real MESSAGE_TYPES for the actual system messages.
|
|
const REG = MESSAGE_TYPES;
|
|
|
|
describe('primitive decoders', () => {
|
|
it('decodes a double (8-byte little-endian)', () => {
|
|
expect(decodeDouble(new Uint8Array(new Float64Array([3.14]).buffer))).toBeCloseTo(3.14, 10);
|
|
expect(decodeDouble(new Uint8Array(new Float64Array([-0]).buffer))).toBe(-0);
|
|
expect(decodeDouble(new Uint8Array(new Float64Array([0]).buffer))).toBe(0);
|
|
expect(decodeDouble(new Uint8Array(new Float64Array([1e30]).buffer))).toBe(1e30);
|
|
});
|
|
|
|
it('decodes a float (4-byte little-endian)', () => {
|
|
expect(decodeFloat(new Uint8Array(new Float32Array([2.5]).buffer))).toBeCloseTo(2.5, 5);
|
|
});
|
|
|
|
it('decodes a sint32 (zigzag varint)', () => {
|
|
// 0 -> 0, 1 -> 2, -1 -> 1, 2 -> 4, -2 -> 3
|
|
expect(decodeSint32(new Uint8Array([0]))).toBe(0);
|
|
expect(decodeSint32(new Uint8Array([2]))).toBe(1);
|
|
expect(decodeSint32(new Uint8Array([1]))).toBe(-1);
|
|
expect(decodeSint32(new Uint8Array([4]))).toBe(2);
|
|
expect(decodeSint32(new Uint8Array([3]))).toBe(-2);
|
|
});
|
|
|
|
it('decodes a uint32 (varint)', () => {
|
|
expect(decodeUint32(new Uint8Array([0]))).toBe(0);
|
|
expect(decodeUint32(new Uint8Array([0x7f]))).toBe(127);
|
|
expect(decodeUint32(new Uint8Array([0x80, 0x01]))).toBe(128);
|
|
});
|
|
|
|
it('decodes a uint64 to BigInt (preserves > 2^53)', () => {
|
|
// 2^53 + 1 exceeds Number.MAX_SAFE_INTEGER. Verify the decoder
|
|
// produces the exact BigInt. Hand-varint for 2^53+1:
|
|
// 7-bit groups, LSB first: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x10
|
|
// (bit 53 = position 4 in the last group = 0b0010000 = 0x10)
|
|
const big = 0x20_0000_0000_0001n; // 2^53 + 1
|
|
const bytes = new Uint8Array([0x81, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10]);
|
|
expect(decodeUint64(bytes)).toBe(big);
|
|
});
|
|
|
|
it('decodes a bool', () => {
|
|
expect(decodeBool(new Uint8Array([0]))).toBe(false);
|
|
expect(decodeBool(new Uint8Array([1]))).toBe(true);
|
|
expect(decodeBool(new Uint8Array([0xff]))).toBe(true);
|
|
});
|
|
|
|
it('decodes a string', () => {
|
|
const utf8 = new TextEncoder().encode('Kerbin');
|
|
const buf = new Uint8Array([utf8.length, ...utf8]);
|
|
expect(decodeString(buf)).toBe('Kerbin');
|
|
});
|
|
|
|
it('decodes a string with non-ASCII characters', () => {
|
|
const utf8 = new TextEncoder().encode('日本語');
|
|
const buf = new Uint8Array([utf8.length, ...utf8]);
|
|
expect(decodeString(buf)).toBe('日本語');
|
|
});
|
|
|
|
it('decodes bytes', () => {
|
|
const payload = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
|
|
const buf = new Uint8Array([payload.length, ...payload]);
|
|
expect(decodeBytes(buf)).toEqual(payload);
|
|
});
|
|
});
|
|
|
|
describe('decodeValue dispatcher', () => {
|
|
it('decodes DOUBLE', () => {
|
|
const bytes = new Uint8Array(new Float64Array([2.71828]).buffer);
|
|
const out = decodeValue(T({ code: TypeCode.DOUBLE }), bytes);
|
|
expect(out).toBeCloseTo(2.71828, 5);
|
|
});
|
|
|
|
it('decodes STRING', () => {
|
|
const utf8 = new TextEncoder().encode('Mun');
|
|
const bytes = new Uint8Array([utf8.length, ...utf8]);
|
|
expect(decodeValue(T({ code: TypeCode.STRING }), bytes)).toBe('Mun');
|
|
});
|
|
|
|
it('decodes CLASS object id (non-null)', () => {
|
|
// 42 as varint
|
|
const id = decodeValue(T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'Vessel' }), new Uint8Array([42]));
|
|
expect(id).toBe(42n);
|
|
});
|
|
|
|
it('decodes CLASS object id (null when 0)', () => {
|
|
expect(
|
|
decodeValue(
|
|
T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'Vessel' }),
|
|
new Uint8Array([0]),
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('decodes ENUMERATION as a sint32', () => {
|
|
expect(
|
|
decodeValue(
|
|
T({ code: TypeCode.ENUMERATION, service: 'SpaceCenter', name: 'VesselType' }),
|
|
new Uint8Array([0]), // Ship
|
|
),
|
|
).toBe(0);
|
|
});
|
|
|
|
it('decodes NONE as null', () => {
|
|
expect(decodeValue(T({ code: TypeCode.NONE }), new Uint8Array(0))).toBeNull();
|
|
});
|
|
|
|
it('throws on unknown type code', () => {
|
|
expect(() => decodeValue(T({ code: 9999 }), new Uint8Array(0))).toThrow(/unknown TypeCode/);
|
|
});
|
|
});
|
|
|
|
describe('collection decoders', () => {
|
|
it('decodes a list of doubles', () => {
|
|
// Construct a KRPC.List message manually:
|
|
// items[0] = 8 bytes for double 1.0
|
|
// items[1] = 8 bytes for double 2.5
|
|
const d1 = new Uint8Array(new Float64Array([1.0]).buffer);
|
|
const d2 = new Uint8Array(new Float64Array([2.5]).buffer);
|
|
const listMsg = KRPC.List.create({ items: [d1, d2] });
|
|
const listBytes = KRPC.List.encode(listMsg).finish();
|
|
const out = decodeList(
|
|
T({ code: TypeCode.LIST, types: [T({ code: TypeCode.DOUBLE })] }),
|
|
listBytes,
|
|
REG,
|
|
);
|
|
expect(out).toEqual([1.0, 2.5]);
|
|
});
|
|
|
|
it('decodes a list of strings', () => {
|
|
const enc = (s: string): Uint8Array => {
|
|
const utf8 = new TextEncoder().encode(s);
|
|
return new Uint8Array([utf8.length, ...utf8]);
|
|
};
|
|
const listMsg = KRPC.List.create({ items: [enc('Kerbin'), enc('Mun')] });
|
|
const listBytes = KRPC.List.encode(listMsg).finish();
|
|
const out = decodeList(
|
|
T({ code: TypeCode.LIST, types: [T({ code: TypeCode.STRING })] }),
|
|
listBytes,
|
|
REG,
|
|
);
|
|
expect(out).toEqual(['Kerbin', 'Mun']);
|
|
});
|
|
|
|
it('decodes an empty list', () => {
|
|
const listMsg = KRPC.List.create({ items: [] });
|
|
const listBytes = KRPC.List.encode(listMsg).finish();
|
|
const out = decodeList(
|
|
T({ code: TypeCode.LIST, types: [T({ code: TypeCode.STRING })] }),
|
|
listBytes,
|
|
REG,
|
|
);
|
|
expect(out).toEqual([]);
|
|
});
|
|
|
|
it('decodes a null list as null', () => {
|
|
const out = decodeList(
|
|
T({ code: TypeCode.LIST, types: [T({ code: TypeCode.STRING })] }),
|
|
new Uint8Array([0]),
|
|
REG,
|
|
);
|
|
expect(out).toBeNull();
|
|
});
|
|
|
|
it('decodes a list of CLASS as a bigint array', () => {
|
|
// items[0] = 7, items[1] = 0 (null)
|
|
const listMsg = KRPC.List.create({ items: [new Uint8Array([7]), new Uint8Array([0])] });
|
|
const listBytes = KRPC.List.encode(listMsg).finish();
|
|
const out = decodeList(
|
|
T({
|
|
code: TypeCode.LIST,
|
|
types: [T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'CelestialBody' })],
|
|
}),
|
|
listBytes,
|
|
REG,
|
|
);
|
|
expect(out).toEqual([7n, null]);
|
|
});
|
|
|
|
it('decodes a tuple of mixed types', () => {
|
|
// tuple: (string "Kerbin", double 0.5)
|
|
const utf8 = new TextEncoder().encode('Kerbin');
|
|
const sBytes = new Uint8Array([utf8.length, ...utf8]);
|
|
const dBytes = new Uint8Array(new Float64Array([0.5]).buffer);
|
|
const tupleMsg = KRPC.Tuple.create({ items: [sBytes, dBytes] });
|
|
const tupleBytes = KRPC.Tuple.encode(tupleMsg).finish();
|
|
const out = decodeTuple(
|
|
T({
|
|
code: TypeCode.TUPLE,
|
|
types: [T({ code: TypeCode.STRING }), T({ code: TypeCode.DOUBLE })],
|
|
}),
|
|
tupleBytes,
|
|
REG,
|
|
);
|
|
expect(out).toEqual(['Kerbin', 0.5]);
|
|
});
|
|
|
|
it('decodes a dictionary of string -> double', () => {
|
|
const utf8 = new TextEncoder().encode('mu');
|
|
const sBytes = new Uint8Array([utf8.length, ...utf8]);
|
|
const dBytes = new Uint8Array(new Float64Array([3.53e12]).buffer);
|
|
const dictMsg = KRPC.Dictionary.create({
|
|
entries: [{ key: sBytes, value: dBytes }],
|
|
});
|
|
const dictBytes = KRPC.Dictionary.encode(dictMsg).finish();
|
|
const out = decodeDictionary(
|
|
T({
|
|
code: TypeCode.DICTIONARY,
|
|
types: [T({ code: TypeCode.STRING }), T({ code: TypeCode.DOUBLE })],
|
|
}),
|
|
dictBytes,
|
|
REG,
|
|
);
|
|
expect(out.get('mu')).toBe(3.53e12);
|
|
});
|
|
|
|
it('decodes a null dictionary as null', () => {
|
|
const out = decodeDictionary(
|
|
T({
|
|
code: TypeCode.DICTIONARY,
|
|
types: [T({ code: TypeCode.STRING }), T({ code: TypeCode.DOUBLE })],
|
|
}),
|
|
new Uint8Array([0]),
|
|
REG,
|
|
);
|
|
expect(out).toBeNull();
|
|
});
|
|
|
|
it('exposes a low-level decodeKrpcList for testing', () => {
|
|
const listMsg = KRPC.List.create({ items: [new Uint8Array([1, 2, 3])] });
|
|
const bytes = KRPC.List.encode(listMsg).finish();
|
|
const out = decodeKrpcList(bytes, KRPC.List);
|
|
// protobufjs returns Node Buffers (which extend Uint8Array). Compare
|
|
// the underlying bytes so this works regardless of wrapper class.
|
|
expect(out).toHaveLength(1);
|
|
expect(Array.from(out[0] as Uint8Array)).toEqual([1, 2, 3]);
|
|
});
|
|
});
|
|
|
|
describe('encodeValue (round-trips)', () => {
|
|
it('encodes a double', () => {
|
|
const bytes = encodeValue(T({ code: TypeCode.DOUBLE }), 1.5);
|
|
expect(decodeValue(T({ code: TypeCode.DOUBLE }), bytes)).toBe(1.5);
|
|
});
|
|
|
|
it('encodes a string', () => {
|
|
const bytes = encodeValue(T({ code: TypeCode.STRING }), 'Kerbol');
|
|
expect(decodeValue(T({ code: TypeCode.STRING }), bytes)).toBe('Kerbol');
|
|
});
|
|
|
|
it('encodes a CLASS object id', () => {
|
|
const bytes = encodeValue(
|
|
T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'Vessel' }),
|
|
42n,
|
|
);
|
|
expect(decodeValue(T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'Vessel' }), bytes)).toBe(42n);
|
|
});
|
|
|
|
it('encodes a null CLASS as id=0', () => {
|
|
const bytes = encodeValue(
|
|
T({ code: TypeCode.CLASS, service: 'SpaceCenter', name: 'Vessel' }),
|
|
null,
|
|
);
|
|
expect(bytes).toEqual(new Uint8Array([0]));
|
|
});
|
|
|
|
it('encodes an ENUMERATION', () => {
|
|
const bytes = encodeValue(
|
|
T({ code: TypeCode.ENUMERATION, service: 'SpaceCenter', name: 'VesselType' }),
|
|
3, // Probe
|
|
);
|
|
expect(
|
|
decodeValue(
|
|
T({ code: TypeCode.ENUMERATION, service: 'SpaceCenter', name: 'VesselType' }),
|
|
bytes,
|
|
),
|
|
).toBe(3);
|
|
});
|
|
|
|
it('encodes a list of doubles', () => {
|
|
const bytes = encodeValue(
|
|
T({ code: TypeCode.LIST, types: [T({ code: TypeCode.DOUBLE })] }),
|
|
[1.0, 2.0, 3.0],
|
|
REG,
|
|
);
|
|
const out = decodeValue(
|
|
T({ code: TypeCode.LIST, types: [T({ code: TypeCode.DOUBLE })] }),
|
|
bytes,
|
|
REG,
|
|
);
|
|
expect(out).toEqual([1.0, 2.0, 3.0]);
|
|
});
|
|
|
|
it('encodes a uint64 BigInt', () => {
|
|
const id = 0x1_0000_0000n; // 2^32
|
|
const bytes = encodeValue(T({ code: TypeCode.UINT64 }), id);
|
|
expect(decodeValue(T({ code: TypeCode.UINT64 }), bytes)).toBe(id);
|
|
});
|
|
|
|
it('encodes a bool', () => {
|
|
expect(decodeValue(T({ code: TypeCode.BOOL }), encodeValue(T({ code: TypeCode.BOOL }), true))).toBe(true);
|
|
expect(decodeValue(T({ code: TypeCode.BOOL }), encodeValue(T({ code: TypeCode.BOOL }), false))).toBe(false);
|
|
});
|
|
|
|
it('rejects SET (not implemented)', () => {
|
|
expect(() =>
|
|
encodeValue(T({ code: TypeCode.SET, types: [T({ code: TypeCode.STRING })] }), new Set(), REG),
|
|
).toThrow(/SET not implemented/);
|
|
});
|
|
});
|
|
|
|
// Suppress unused-warning for the TYPE const helper by using it once.
|
|
const _checkTypeHelper: KrpcType = T({ code: TypeCode.STRING });
|
|
void _checkTypeHelper;
|
|
// Also pin the protobufjs default import to confirm we still have it.
|
|
const _pb: typeof protobuf = protobuf;
|
|
void _pb;
|