phase 1c-extract: typed kRPC service client + SpaceCenter extract
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.)
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import protobuf from 'protobufjs';
|
||||
import {
|
||||
decodeValue,
|
||||
encodeValue,
|
||||
decodeDouble,
|
||||
decodeFloat,
|
||||
decodeSint32,
|
||||
decodeUint32,
|
||||
decodeUint64,
|
||||
decodeBool,
|
||||
decodeString,
|
||||
decodeBytes,
|
||||
decodeList,
|
||||
decodeTuple,
|
||||
decodeDictionary,
|
||||
decodeKrpcList,
|
||||
} from '../src/decoder.js';
|
||||
import { TypeCode, type KrpcType } from '../src/types.js';
|
||||
import { MESSAGE_TYPES, KRPC } from '../src/schema.js';
|
||||
|
||||
const T = (t: Partial<KrpcType>): 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;
|
||||
Reference in New Issue
Block a user