ワンクリックで
details about the ksa game code and behavior
npx skills add https://github.com/meow-sci/purrtty --skill ksaこのコマンドをClaude Codeにコピー&ペーストしてスキルをインストール
details about the ksa game code and behavior
npx skills add https://github.com/meow-sci/purrtty --skill ksaこのコマンドをClaude Codeにコピー&ペーストしてスキルをインストール
| name | ksa |
| description | details about the ksa game code and behavior |
StarMap is a mod loader only. It is used to run the game and link mods in at runtime. The only interaction with StarMap is through the C# lifecycle attribute annotations on the mod class — there is no other StarMap API to use.
Mods are C# 10 classes decorated with StarMap attributes:
using StarMap.API;
using KSA;
[StarMapMod]
public class Mod
{
public bool ImmediateUnload => false;
[StarMapImmediateLoad] public void OnImmediateLoad() { }
[StarMapAllModsLoaded] public void OnFullyLoaded() { Patcher.Patch(); }
[StarMapBeforeGui] public void OnBeforeUi(double dt) { }
[StarMapAfterGui] public void OnAfterUi(double dt) { }
[StarMapUnload] public void Unload() { Patcher.Unload(); }
}
These attributes are the complete StarMap interface. Do not attempt to call other StarMap APIs or use StarMap for anything beyond these lifecycle hooks.
Patcher.cs; call Patcher.Patch() in OnFullyLoaded and Patcher.Unload() in UnloadConsole.WriteLine for loggingWhen you need to understand game types, APIs, or behavior:
decomp/ksa/ — they contain all available information and are much easier to readImportant: The decompiled sources may be outdated. The running binary can have a completely different internal structure — field names that appear in decompiled code may not exist at runtime. When in doubt, use the runtime reflection dump strategy to discover the real structure. See debug.md.
When decompiled source field names don't match the actual binary (reflection returns null, counts show -1, etc.):
GetType().FullName and all fields via BindingFlags.Public | NonPublic | Instance | DeclaredOnlyList<T> / IList fields — the game may store typed components in a generic Components list rather than named fields<mod>/DEBUG) for offline analysisSee debug.md for complete helper code, the DumpPartsWithComponents pattern, and a worked example of how LightModule+TemplateData was discovered inside PartTemplate.Components.
StarMap loads each mod into its own AssemblyLoadContext (ALC). By default, two mods that both compile against the same .lib project will each get independent copies of that assembly with separate static state.
To share an assembly (and its static state) across mods, declare a dependency in mod.toml:
[[StarMap.ModDependencies]]
ModId = "blinky"
Optional = true
ImportedAssemblies = [
"MeowSci.BlinkyLib"
]
When the dependent mod's ALC tries to load an assembly whose name appears in ImportedAssemblies, it delegates to the dependency mod's ALC — returning the exact same Assembly object. Same Assembly → same Type objects → same static fields → shared singleton state.
The dependency mod can optionally declare which assemblies it exposes:
# in the dependency mod's mod.toml
[StarMap]
ExportedAssemblies = ["MeowSci.BlinkyLib"]
Dependent sets ImportedAssemblies | Dependency sets ExportedAssemblies | Shared assemblies |
|---|---|---|
| No | No | Entry assembly only |
| Yes | No | Exactly what ImportedAssemblies lists |
| No | Yes | Everything in ExportedAssemblies |
| Yes | Yes | Intersection of both lists |
.lib assemblies only — the mod entry assembly (e.g. MeowSci.Blinky) is private and never imported by other mods.ImportedAssemblies lists .lib assembly names — e.g. "MeowSci.BlinkyLib", not "MeowSci.Blinky".Optional = true so each mod remains independently installable. Guard code paths that depend on the other mod being present..lib deps may need importing too — if blinky.lib → ksa-abstractions.lib and both mods need the same GameThread static state, import MeowSci.KsaAbstractions as well..csproj <ProjectReference> to the .lib project provides compile-time types. At runtime, ImportedAssemblies redirects the load to the dependency's ALC instead of loading the local copy.var vehicles = Universe.CurrentSystem?.Vehicles.GetList(); // List<Vehicle>
Vehicle? controlled = Program.ControlledVehicle; // currently player-controlled vehicle
double simTime = Universe.GetElapsedSimTime();
vehicle.Id — string identifiervehicle.Parent — celestial body the vehicle orbits; must match between vehicles for teleport operations to be validvehicle.BodyRates — double3 angular velocity (rad/s); guard against NaN before usevehicle.Body2Cce — direct doubleQuat property (body frame → body-fixed frame)vehicle.Orbit — current orbital state; use vehicle.Orbit.OrbitLineColor when creating new orbitsvehicle.IsEditedVehicle — bool, true when in VAB/editorFor physics data (AccelerationBody, NavBallData, FlightComputer, TotalMass, render override patching) see vehicle-api.md.
var elapsed = Universe.GetElapsedSimTime(); // returns a time value
double seconds = elapsed.Seconds(); // convert to double seconds
vehicle.Parent.Mass // double — body mass (kg)
vehicle.Parent.MeanRadius // double — body mean radius (m)
vehicle.Parent.GetCci2Cce() // doubleQuat — CCI-to-CCE frame rotation
Top-level parts are accessed via vehicle.Parts.Parts. Each Part has a SubParts collection forming a tree. Recurse to reach all parts:
void SetPartScaleRecursive(Part part, float factor)
{
part.Scale = new double3(factor, factor, factor);
foreach (var sub in part.SubParts)
SetPartScaleRecursive(sub, factor);
}
// Apply to all parts on a vehicle:
foreach (var part in vehicle.Parts.Parts)
SetPartScaleRecursive(part, factor);
part.Scale is a double3 — set all three axes to the same value for uniform scaling.
part.Id — string identifier (e.g. "pixel_3_7_a")part.DisplayName — human-readable namepart.IsSubPart — whether this is a child subpartpart.PartParent — parent Part in the tree (nullable)part.TreeChildren — IList<Part> direct childrenParts contain typed modules accessed via generic Get<T>() calls:
// All modules of type T on a single part and its subtree:
EngineController[] engines = part.SubtreeModules.Get<EngineController>();
// All modules of type T across the entire vehicle:
EngineController[] engines = vehicle.Parts.Modules.Get<EngineController>();
After modifying module state (e.g. activating/deactivating engines), call:
vehicle.Parts.RecomputeAllDerivedData();
For engine control details see vehicle-api.md.
Parts can be created and merged into a live vehicle's part tree at runtime using PartTree.Merge(). This is the same mechanism the vehicle editor uses, so the game handles it correctly.
// 1. Look up the PartTemplate from ModLibrary.
// IMPORTANT: Use Get<PartTemplate>(), NOT TryGet<PartTemplate>()
// TryGet does NOT support PartTemplate and always returns false.
// Get throws NullReferenceException if the id is unknown.
PartTemplate template;
try { template = ModLibrary.Get<PartTemplate>("CorePropulsionA_Prefab_EngineA2"); }
catch (Exception ex) { Console.WriteLine($"template not found: {ex.Message}"); return; }
// 2. Create the Part with a unique string id and the template
var part = new Part("my_part_id", template);
// 3. Set position relative to the parent part (in parent's assembly frame)
part.PositionParentAsmb = new double3(x, y, z);
part.Asmb2ParentAsmb = new doubleQuat(0, 0, 0, 1); // identity = no rotation
// 4. Merge into the vehicle tree under a parent part
// Merge() internally calls RecomputeAllDerivedData() once.
// Returns false if the merge failed.
Part parentPart = vehicle.Parts.Root; // or any other valid Part
bool ok = vehicle.Parts.Merge(parentPart, part);
The part template ID is the exact string key used in the game's XML — e.g. CorePropulsionA_Prefab_EngineA2. List available IDs with the debug reflection approach shown in debug.md.
Engines require two extra steps beyond a basic Merge, or they will never fire:
The problem: PartTree.Merge() wires the tree hierarchy but does NOT create Part.Connection objects. The game's ResourceManager builds its propellant-flow graph by walking Part.Connections (not the tree). Without a connection from the engine to a fuel-carrying part, ResourceManager.ResourceAvailable() always returns false and the engine is starved every tick — IsActive is irrelevant.
Step 1 — Establish a fuel connection:
Part implements IConnector, so you can create a direct resource connection between two parts:
// Find a part on the vehicle that has Tank modules (fuel / oxidizer)
Part? FindFuelPart(Vehicle vehicle)
{
foreach (var p in vehicle.Parts.Parts)
if (p.SubtreeModules.Get<Tank>().Length > 0 && !p.IsSubPart)
return p;
return null;
}
// After Merge(), connect the new engine part to the fuel part
Part? fuelPart = FindFuelPart(vehicle);
if (fuelPart != null)
{
bool connected = Part.Connection.Connect(enginePart, fuelPart);
// Part.Connection.Connect() is static and takes two IConnector arguments.
// Part itself implements IConnector so you can pass Parts directly.
// Returns false if either side is already connected or blocked.
}
Part.Connection.Connect() adds the connection to both parts' Connections lists. After this, ResourceManager.PopulateGraph() (called inside RecomputeAllDerivedData) walks those connections and discovers the fuel tanks.
Step 2 — Recompute after all connections are established:
Each Merge() call already triggers RecomputeAllDerivedData(), but that runs before you call Part.Connection.Connect(). Call it explicitly once more after all connections are wired:
vehicle.Parts.RecomputeAllDerivedData();
This rebuilds every ResourceManager graph, now including the new connections. Without this the engines will still be starved.
Step 3 — Set MinimumThrottle after recompute:
The EngineController.MinimumThrottle field prevents firing below a threshold. The default is 1.0 (full throttle only for SRBs) or 0.1 for liquid engines. You can lower it, but only after Merge() + RecomputeAllDerivedData() because SubtreeModules.Get<EngineController>() returns empty for a part that hasn't been through recompute yet:
var controllers = enginePart.SubtreeModules.Get<EngineController>();
foreach (var c in controllers)
c.MinimumThrottle = 0.0001f; // fire at any throttle above zero
Step 4 — Activate with SetIsActive:
var controllers = vehicle.Parts.Modules.Get<EngineController>();
foreach (var c in controllers)
if (c.Parent.Id.StartsWith("my_prefix_"))
c.SetIsActive(null, true);
SetIsActive first argument is a nullable Vehicle? (not a "caller" object), pass null.
// 1. Template lookup
PartTemplate template;
try { template = ModLibrary.Get<PartTemplate>(enginePartId); }
catch { return; }
// 2. Create + position
var enginePart = new Part(uniqueId, template);
enginePart.PositionParentAsmb = new double3(x, y, z);
enginePart.Asmb2ParentAsmb = new doubleQuat(0, 0, 0, 1);
// 3. Merge into vehicle tree (triggers RecomputeAllDerivedData internally)
bool merged = vehicle.Parts.Merge(vehicle.Parts.Root, enginePart);
if (!merged) return;
// 4. Wire the resource connection so the engine can find fuel
Part? fuelPart = FindFuelPart(vehicle); // finds first part with Tank modules
if (fuelPart != null)
Part.Connection.Connect(enginePart, fuelPart);
// 5. Recompute AGAIN after connections are wired — this rebuilds ResourceManager graphs
vehicle.Parts.RecomputeAllDerivedData();
// 6. Now SubtreeModules is fully populated — set throttle limits
foreach (var c in enginePart.SubtreeModules.Get<EngineController>())
c.MinimumThrottle = 0.0001f;
// 7. Activate
foreach (var c in enginePart.SubtreeModules.Get<EngineController>())
c.SetIsActive(null, true);
Disconnect resource connections first, then split the part from the tree:
// Disconnect resource connections (prevents dangling graph references)
foreach (var conn in enginePart.Connections.ToList())
{
try { conn.Disconnect(); } catch { }
}
// Split removes the part from the PartTree and triggers RecomputeAllDerivedData
vehicle.Parts.Split(enginePart);
Understanding this prevents future mistakes:
Part.Connections — list of Part.Connection objects; each connection links two IConnectorsPart.Connection.Connect(IConnector a, IConnector b) — static factory; Part itself implements IConnectorResourceManager — created fresh per RocketCore during RecomputeAllDerivedData()ResourceManager.PopulateGraph() — starts at the engine's FullPart (= PartParent ?? self) and does a BFS over Part.Connections to discover Tank modulesFlowRule.NearestToFurtherestSameStage — used for EngineController; only considers tanks in the same stageResourceAvailable() — returns false if the graph is empty (no connections → no tanks found → engine always starved)TreeParent / TreeChildren) is irrelevant to fuel flow — only Connections matterKittenEva is a special vehicle subtype. Detect it via:
vehicle.GetType().Name == "KittenEva"
KittenEva renders through CharacterAvatar.Core.Scale (a float where 0.01f = 1:1 game scale, i.e. multiply your desired factor by 0.01f). Access it via reflection since it is not part of the public Vehicle API:
var allFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
var renderable = vehicle.GetType().GetField("_renderable", allFlags)?.GetValue(vehicle);
var avatar = renderable?.GetType().GetField("_characterAvatar", allFlags)?.GetValue(renderable);
var coreField = avatar?.GetType().GetField("Core", allFlags);
var core = coreField?.GetValue(avatar);
// Try field first, then property
var scaleField = core?.GetType().GetField("Scale", allFlags);
var scaleProp = core?.GetType().GetProperty("Scale", allFlags);
if (scaleField != null && scaleField.FieldType == typeof(float))
{
scaleField.SetValue(core, factor * 0.01f);
coreField!.SetValue(avatar, core); // write struct back
}
else if (scaleProp != null && scaleProp.PropertyType == typeof(float))
{
scaleProp.SetValue(core, factor * 0.01f);
coreField!.SetValue(avatar, core);
}
vehicle.Parts.Parts still iterates KittenEva parts but scaling them has no visual effect — the Core.Scale path above is what drives rendering. Apply both when doing a generic "scale any vehicle" implementation.
For the full KittenEva API including animations, expressions, and casting patterns see kitten-eva.md.
KSA uses double-precision coordinate frames:
body2Cci quaternion converts from it to CCITo move a vehicle to an absolute position, bypassing all physics simulation, call Teleport. The pattern (e.g. "weld" source to target):
double3 tgtPosCci = target.GetPositionCci();
double3 tgtVelCci = target.GetVelocityCci();
doubleQuat tgtBody2Cci = target.GetBody2Cci();
// Offset expressed in target's body frame (metres):
double3 offsetCci = new double3(offsetX, offsetY, offsetZ).Transform(tgtBody2Cci);
double3 newPosCci = tgtPosCci + offsetCci;
// Orientation: compose delta rotation with target orientation, then convert to CCE
doubleQuat deltaRot = EulerDegreesToQuat(pitchDeg, yawDeg, rollDeg);
doubleQuat newBody2Cci = doubleQuat.Concatenate(deltaRot, tgtBody2Cci);
doubleQuat cci2Cce = source.Parent.GetCci2Cce();
doubleQuat newBody2Cce = doubleQuat.Concatenate(newBody2Cci, cci2Cce).NormalizedOrZero();
Orbit newOrbit = Orbit.CreateFromStateCci(
source.Parent,
Universe.GetElapsedSimTime(),
newPosCci,
tgtVelCci, // match target velocity to stay co-moving
source.Orbit.OrbitLineColor
);
source.Teleport(newOrbit, newBody2Cce, target.BodyRates);
Key points:
Teleport takes (Orbit, doubleQuat body2Cce, double3 bodyRates) — it overwrites physics state completely each frame.NormalizedOrZero() on computed quaternions before passing to TeleportdoubleQuat.Concatenate(q1, q2) composes rotations (q2 applied first, then q1 — same convention as Quaternion.Concatenate in .NET)source.Parent must equal target.Parent; validate before teleporting or the coordinate math is invalidTeleport every frame (e.g. in OnAfterUi)BodyRates for NaN, especially when rotation is unlocked: if (double.IsNaN(rates.X) || ...) rates = double3.zero;doubleQuat EulerDegreesToQuat(float pitchDeg, float yawDeg, float rollDeg)
{
double cp = Math.Cos(pitchDeg * Math.PI / 360), sp = Math.Sin(pitchDeg * Math.PI / 360);
double cy = Math.Cos(yawDeg * Math.PI / 360), sy = Math.Sin(yawDeg * Math.PI / 360);
double cr = Math.Cos(rollDeg * Math.PI / 360), sr = Math.Sin(rollDeg * Math.PI / 360);
var qPitch = new doubleQuat(sp, 0, 0, cp);
var qYaw = new doubleQuat(0, sy, 0, cy);
var qRoll = new doubleQuat(0, 0, sr, cr);
return doubleQuat.Concatenate(doubleQuat.Concatenate(qYaw, qPitch), qRoll);
}
// doubleQuat constructor: (x, y, z, w)
KSAColor.Xkcd provides named colors. Cast to (float4) for ImGui:
ImGui.TextColored((float4)KSAColor.Xkcd.Custard, "label");
ImGui.PushStyleColor(ImGuiCol.Button, ImGui.GetColorU32((float4)KSAColor.Xkcd.HotPink));
Notable names: Custard, RadioactiveGreen, Orangeish, GreenApple, OrangishRed, BrightMagenta, HotPink, CanaryYellow, BrightLightBlue.
Custom top-level menus can be injected into the game's title bar menu (alongside File / Universe / View) using a Harmony Transpiler on Program.DrawMenuBar. This is the only viable approach because injection must happen inside the game's existing ImGui.BeginMenuBar() / ImGui.EndMenuBar() block. Setting viewport.MenuBarInUse = true inside the open menu is required to suppress game hotkeys and prevent the bar from auto-hiding.
See game-menus.md for the complete pattern including the transpiler code, injection offset rationale, and all available ImGui menu calls.
KSA cameras (OrbitController, FlyController) can be intercepted via Harmony prefix on OnFrame. Return false to suppress default camera behavior. Camera uses ECL (Ecliptic) coordinates (distinct from vehicle CCI/CCE frames).
See camera.md for full details including Transform3D, Controller.Camera.Following, orbit math, and look-at helpers.
| Precision | Scalar | Vector | Matrix | Quaternion |
|---|---|---|---|---|
| 32-bit | float | float2, float3, float4 | float4x4 | floatQuat |
| 64-bit | double | double3, double4 | double4x4 | doubleQuat |
All from Brutal.Numerics.
double3.Normalize(v) // normalize vector
v.Length() // vector magnitude
double3.Dot(a, b) // dot product
double3.Cross(a, b) // cross product
double3.Lerp(a, b, t) // linear interpolation (t ∈ [0,1])
doubleQuat.Slerp(a, b, t) // spherical linear interpolation
v.Transform(quat) // rotate vector by quaternion
float3.Pack(in double3) // double3 → float3
floatQuat.Pack(doubleQuat) // doubleQuat → floatQuat
float4x4.CreateTranslation(float3)
float4x4.CreateFromQuaternion(floatQuat)
var music = ModLibrary.Get<MusicPlayList>("AssetName");
music.PlayMusic(out ChannelWrapper? channel);
var sound = ModLibrary.Get<MultiSound>("AssetName");
sound.Play();
Assets are defined in an Assets.xml file in the mod directory.
Use the mods folder and the mod name that matches the mod folder name (the mods kebab case name)
// common root for userland files
var myDocuments = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var userlandModsDir = Path.Combine(myDocuments, "My Games", "Kitten Space Agency", "mods");
// mod specific
var configDirectory = Path.Combine(userlandModsDir, "fixme-mod-name");
return Path.Combine(configDirectory, "FIXME_FILENAME_HERE");
The game processes keyboard input through a short-circuit chain in Program.cs:
GameSettings.OnKeyAll → Popup.OnKeyAll → ConsoleWindow.OnKey → ConsoleWindow.IsOpen → Editor?.OnKey → ...
If any handler returns true, all downstream handlers are skipped. This means a Harmony prefix on GameSettings.OnKeyAll that returns true will block the in-game console (\ toggle, Enter submit) and all other handlers.
When a mod has InputText widgets, typing triggers game hotkeys. To block them only for your mod:
GameSettings.OnKeyAll with a prefix that checks a mod-scoped flagBegin/End blocks using ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows) && ImGui.GetIO().WantTextInputWantTextInput alone — it's a global flag that's true for any active text input, including the game's in-game consoleSee the ImGui skill for the full implementation pattern.
How to use tomlyn with KSA
First inlclude the tomlyn nuget in the csharp project
<ItemGroup>
<PackageReference Include="Tomlyn" Version="0.19.0"/>
</ItemGroup>
And in the mods CopyCustomContent csproj file section, add the Tomlyn dll as a reference so it gets copied to the mod output directory, for example
<Target Name="CopyCustomContent" AfterTargets="AfterBuild">
<!-- other stuff -->
<Copy SourceFiles="$(TargetDir)Tomlyn.dll" DestinationFolder="$(DistDir)"/>
</Target>
How to import and use
using Tomlyn;
using Tomlyn.Model;
// example loading
// Use TryToModel for graceful error handling with Tomlyn
if (!Toml.TryToModel<TomlTable>(tomlContent, out var tomlTable, out var diagnostics))
{
foreach (var diagnostic in diagnostics)
{
// Console.WriteLine($"TOML parsing error in {filePath}: {diagnostic}");
}
return null;
}