Custom Launch Locations

From KSP 2 Modding Wiki

Goal

In this tutorial I will show how to change the location of Launch Pad 1 to be anywhere you want

Setup

Required Mods:
  • SpaceWarp Template
  • SpaceWarp
  • Patch Manager
  • Text Asset Dumper (technically not needed but I recommend installing this and doing the one time setup described below)
Required tools:
  • dnSpy (or any other decompiler)
Steps:
  1. Install SpaceWarp, Patch Manager, and Text Asset Dumper.
  2. Launch KSP2.
  3. Open Settings > Mods, then scroll down to Patch manager and make sure "Always Invalidated Cache" is on.
  4. Go back to the title screen, and click the "Dump" button
  5. If you haven't already done so, make a project for your mod using the SpaceWarp Template

Creating a custom launch site

Throughout this guide, I will be using Laythe as an example, just because that's where I was trying to make my mod launch rockets.

Creating a location

Open your project folder on your system explorer. Navigate to plugin_template/patches, and in that folder create a text file with a name that ends in .patch. In that file, put the following:

@use "builtin:dictionary";
@use "builtin:list";

@patch celestial_bodies;

:json #Laythe {
    data: $value:set("LocalSimObjectsData", [
        {
            "Name": "LaunchPad_spawn_01",
            "RelativeTo": "",
            "ReferenceFrame": 1,
            "LocalPosition": {
                "x": 0.0,
                "y": 0.0,
                "z": 0.0
            },
            "LocalRotation": {
                "x": 0.0,
                "y": 0.0,
                "z": 0.0,
                "w": 0.0
            },
            "FixedGuid": true
        }
            ]);
}

Replace "Laythe" with whatever celestial body you want your object to appear on, and change the x, y, and z coordinates under LocalPosition to whatever local coordinates you'd like. An easy way to get a specific location on the surface is to land something there, save the game with your craft active, and then read the save file to see the position of your craft.

NOTE: If you want to make a custom location on Kerbin, you need to use $value:add rather than $value:set.

Patching KSP.Game.OABProvider

The OABProvider class is an interface for generating and using ObjectAssemblyBuilder objects (environments like the VAB that allow for construction). Eventually, the developers plan to implement many OABs through colonies and orbital assembly environments, and once they do this tutorial will probably be a LOT shorter. Unfortunately, at the moment the OABProvider class is hardcoded in many places to only generate one specific type of OAB: the VAB on Kerbin, with its predetermined launch sites.

In your project, make a new class and add the following harmony patches to it:

[HarmonyPrefix]
[HarmonyPatch(typeof(OABProvider), nameof(OABProvider.GetSurfaceLaunchPosition))]
public static bool GetSurfaceLaunchPosition(OABProvider __instance, string celestialBodyName, string launchPadName, ref Position __result)
{
    GameInstance game = GameManager.Instance.Game;
    CelestialBodyComponent celestialBodyComponent = game.UniverseModel.FindCelestialBodyByName(celestialBodyName);
    FramePositionState framePositionState;
    TransformModel transformModel = (TransformModel)game.UniverseModel.FindSimObjectByNameKey(launchPadName).transform;
    __result = new Position(celestialBodyComponent.transform.GetTransformFrame(TransformFrameType.Body), transformModel.localPosition);
    return false;
}

[HarmonyPrefix]
[HarmonyPatch(typeof(OABProvider), nameof(OABProvider.GetSurfaceLaunchLocation))]
public static bool GetSurfaceLaunchLocation(OABProvider __instance, string launchLocationName, ref SerializedLocation __result)
{
    string celestialBodyName = "Laythe";
    SerializedLocation serializedLocation = default(SerializedLocation);
    serializedLocation.surfaceLocation = new SerializedSurfaceLocation?(new SerializedSurfaceLocation
    {
        parentGuid = celestialBodyName,
        objectName = launchLocationName
    });
    serializedLocation.LocationType = LocationType.SurfaceLocation;
    CelestialBodyComponent celestialBodyComponent = GameManager.Instance.Game.UniverseModel.FindCelestialBodyByName(celestialBodyName);
    serializedLocation.originatingSimObject = celestialBodyComponent.SimulationObject.GlobalId;
    serializedLocation.launchSituationUnknown = launchLocationName == "Dock_spawn_01";
    __result = serializedLocation;
    return false;
}

In the second method, change the value of celestialBodyName to be whatever celestial body you plan to be launching rockets from.

These patches exist to tell the game to not only look for our launchpad on Kerbin, and in the case of the second function, to tell it to set the reference frame to be whatever world we actually put the launchpad on.

NOTE: If you've never used harmony patches before, this is a good time to look into the basics of how they work and the basic pattern we're using here to patch code copied in from the decompiler.

Courtesy of @evil.dana on discord
Courtesy of @evil.dana on discord
About positions in KSP2:

Trying to store positions at a planetary (let alone interstellar) scale with a standard XYZ coordinate system would be a surefire way to encounter constant game breaking rounding issues. To get around this, the developers have created a system where everything takes place in different reference frames. If you're in the SOI of a planet or moon, then your reference frame will be a coordinate system originating from that celestial body. This coordinate system rotates with the celestial body, so an object at rest on the surface doesn't need to have its position updated at all.

Basically, the main takeaway here is that whenever we set the position of something, we usually need to also tell the game what reference frame we're working in.

Patching UniverseObserver

UniverseObserver contains a method called ApplyLaunchSiteCameraGimbalState, which is set to load specifically Kerbin when we go to launch a rocket. Just like with the methods from OABProvider, we need to patch that method and replace the hardcoded string "Kerbin" with our desired celestial body.

if (!string.IsNullOrEmpty(text) && game.SpaceSimulation.GetSurfaceObjectPosition("Kerbin", text, out framePositionState))

...becomes...

if (!string.IsNullOrEmpty(text) && game.SpaceSimulation.GetSurfaceObjectPosition("Laythe", text, out framePositionState))

Patching SpaceSimulation

At this point, the game has completed the creation of your rocket and is attempting to spawn it into the world. The method SyncVesselToLocation is the final step where the game puts it on the launch pad and turns physics on. Sadly, the game is looking for our launchpad the wrong way, and will not find it unless we tell it to look the correct way. At this point there's a LOT of stuff going on, so this patch is a bit messy. Copy the decompiled source code into a harmony patch just like we've done for all the others, make sure to put a "return false" at the very end of the method so the compiler is happy, and then look at the very end of the method. Find this block of code at the bottom:

__instance.GetSurfaceObjectPosition(parentGuid, objectName, out simulationObjectState.position);
if (simulationObjectState.position.referenceTransformGuid != null)
{
    GeographicPositionState geographicPositionState;
    __instance.GetGeographicPosition(simulationObjectState.position.referenceTransformGuid, simulationObjectState.position, out geographicPositionState);
    geographicPositionState.altitudeFromRadius += (double)vesselComponent.OffsetToGround;
    geographicPositionState.heading = __instance.GetLaunchSiteHeading(geographicPositionState, simulationObjectState.position.localRotation);
    __instance.GetBodyRelativePosition(geographicPositionState, out simulationObjectState.position);
    RigidbodyState rigidbodyState3 = new RigidbodyState
    {
        referenceTransformGuid = simulationObjectState.position.referenceTransformGuid,
        referenceFrameType = simulationObjectState.position.referenceFrameType,
        localPosition = simulationObjectState.position.localPosition,
        localRotation = simulationObjectState.position.localRotation,
        localVelocity = Vector3d.zero,
        localAngularVelocity = Vector3d.zero
    };
    simulationObject.SetState(new SimulationObjectState?(simulationObjectState), __instance._universeModel);
    vesselComponent.SetState(vesselState, __instance._universeModel);
    simulationObject.FindComponent<RigidbodyComponent>().SetState(rigidbodyState3, __instance._universeModel);
    return false;
}

And replace it with this:

TransformModel transform = (TransformModel)GameManager.Instance.Game.UniverseModel.FindSimObjectByNameKey(objectName).transform;
SimulationObjectState state = new SimulationObjectState();
state.position = FramePositionState.FromTransform(transform);
GeographicPositionState geographicPositionState;
__instance.GetGeographicPosition(state.position.referenceTransformGuid, state.position, out geographicPositionState);
//geographicPositionState.heading = __instance.GetLaunchSiteHeading(geographicPositionState, position.localRotation);
Vector3d relSurfaceNVector = GameManager.Instance.Game.UniverseModel.FindCelestialBodyByName(location.surfaceLocation?.parentGuid).GetRelSurfaceNVector(geographicPositionState.latitude, geographicPositionState.longitude);
Vector3d vector3d = Vector3d.Cross(relSurfaceNVector, Vector3d.up);
QuaternionD quaternionD = QuaternionD.AngleAxis(Vector3d.SignedAngle(relSurfaceNVector, Vector3d.up, vector3d), Vector3d.right);
QuaternionD quaternionD2 = QuaternionD.identity;
Vector3d vector3d2 = new Vector3d(relSurfaceNVector.x, 0.0, relSurfaceNVector.z);
if (vector3d2.sqrMagnitude > 5E-324)
{
    quaternionD2 = QuaternionD.AngleAxis(Math.Atan2(vector3d2.x, vector3d2.z) * 57.29577951308232, Vector3d.up);
}
QuaternionD geographicPositionHeading = quaternionD2 * quaternionD;

Vector3d vector3d3 = state.position.localRotation * Vector3.forward;
geographicPositionState.heading =  Vector3d.SignedAngle(geographicPositionHeading * Vector3.forward, vector3d3, relSurfaceNVector);
__instance.GetBodyRelativePosition(geographicPositionState, out state.position);

state.position.localPosition.y += vesselComponent.OffsetToGround;
state.position.localRotation.w = 1.0;

SimulationObjectState simulationObjectState = new SimulationObjectState
{
    position = new FramePositionState
    {
        referenceTransformGuid = transform.bodyFrame.transform.Guid,
        referenceFrameType = transform.bodyFrame.type,
        localPosition = state.position.localPosition,
        localRotation = state.position.localRotation
    }
};

RigidbodyState rigidbodyState3 = new RigidbodyState
{
    referenceTransformGuid = transform.bodyFrame.transform.Guid,
    referenceFrameType = transform.bodyFrame.type,
    localPosition = state.position.localPosition,
    localRotation = state.position.localRotation,
    localVelocity = Vector3d.zero,
    localAngularVelocity = Vector3d.zero
};
simulationObject.SetState(new SimulationObjectState?(simulationObjectState), __instance._universeModel);
vesselComponent.SetState(vesselState, __instance._universeModel);
simulationObject.FindComponent<RigidbodyComponent>().SetState(rigidbodyState3, __instance._universeModel);
return false;

And with this, you should be done!