Skip to content

Converting Game Commands to Game Actions

Margen67 edited this page Apr 22, 2021 · 2 revisions

This tutorial will cover how to convert basic Game Commands into the Game Actions framework. For this tutorial we will convert GAME_COMMAND_CHANGE_SURFACE_STYLE to SurfaceSetStyleAction.

Analysis

Calling

Start by working out how the game command is called. This can be done for looking for all references to GAME_COMMAND_CHANGE_SURFACE_STYLE. From this search we find there are 3 places where it is called from. They are all from tools. All of the calls are the last call of a function and no state from the output of the action are used. This makes things very easy as there is no requirement to use callbacks or complex return values.

                gGameCommandErrorTitle = STR_CANT_CHANGE_LAND_TYPE;
                game_do_command(
                    gMapSelectPositionA.x, 1, gMapSelectPositionA.y, gLandToolTerrainSurface | (gLandToolTerrainEdge << 8),
                    GAME_COMMAND_CHANGE_SURFACE_STYLE, gMapSelectPositionB.x, gMapSelectPositionB.y);
                gCurrentToolId = TOOL_UP_DOWN_ARROW;

Notice that the error title is STR_CANT_CHANGE_LAND_TYPE this will be used for failure returning within the game action function. Setting the toolId at the end does not depend on the output of this function and should be safe to do even with network delay. If it isn't it may need wrapped in a callback.

Game Command Function

Refer to Game Commands for notes on the function.

Create New Class

Create New Header

For ever game action there is a header created in the src/openrct2/actions/ folder. The header should have the same name as the class. We will call it SurfaceSetStyleAction.hpp to be consistent with the naming convention. Create a stub class for SurfaceSetStyleAction.

#pragma once

#include "GameAction.h"

DEFINE_GAME_ACTION(SurfaceSetStyleAction, GAME_COMMAND_CHANGE_SURFACE_STYLE, GameActionResult)
{
private:

public:
    SurfaceSetStyleAction()
    {
    }

    GameActionResult::Ptr Query() const override
    {
    }

    GameActionResult::Ptr Execute() const override
    {

    }
};

Register Game Action

After creating the game action you can add it to GameActionRegistration.cpp by including the file and adding an entry in the Register() function.

...
Register<SurfaceSetStyleAction>();
...

Work out the Parameters

Have a look at the game command and make a list of all of the parameters.

  1. int32_t x0,
  2. int32_t y0,
  3. int32_t x1,
  4. int32_t y1,
  5. uint8_t surfaceStyle,
  6. uint8_t edgeStyle,
  7. uint8_t flags

Parameter 1-4 is a map range and should use struct MapRange. Parameter 5-6 are the styles that are set, both are required in our action. Parameter 7 is the flags, this is no longer passed directly into the function and is therefore not required.

Add Member Variables and Constructor

From the previous knowledge we can now create the member variables.

private:
    MapRange _range;
    uint8_t _surfaceStyle;
    uint8_t _edgeStyle;

And create an appropriate constructor.

    SurfaceSetStyleAction(MapRange range, uint8_t surfaceStyle, uint8_t edgeStyle)
        : _range(range)
        , _surfaceStyle(surfaceStyle)
        , _edgeStyle(edgeStyle)
    {
    }

Add Serialiser

Game Actions need a serialise function to send the parameters over the network. Every member variable must be accounted for. This same function is used for serialising and deserialising. All member variables must be accessed by reference so don't try use any intermediate variables in this function.

    void Serialise(DataSerialiser & stream) override
    {
        GameAction::Serialise(stream);

        stream << DS_TAG(_range) << DS_TAG(_surfaceStyle) << DS_TAG(_edgeStyle);
    }

Query Function

I like to create the query function by copying and pasting the game command directly into the game action and massaging until it fits.

Verify Parameters

        gCommandExpenditureType = RCT_EXPENDITURE_TYPE_LANDSCAPING; // This will become the result expenditure type

        x0 = std::max(x0, 32); // This section checks range parameters are valid
        y0 = std::max(y0, 32);
        x1 = std::min(x1, (int32_t)gMapSizeMaxXY);
        y1 = std::min(y1, (int32_t)gMapSizeMaxXY);
        // There has been no check to confirm that these values are normalised! What would happen if x0 was > x1?
        int32_t xMid, yMid;

        xMid = (x0 + x1) / 2 + 16;
        yMid = (y0 + y1) / 2 + 16;

        int32_t heightMid = tile_element_height(xMid, yMid);

        gCommandPosition.x = xMid;
        gCommandPosition.y = yMid;
        gCommandPosition.z = heightMid;
        // This section defines where command took place it will be placed in the result.

New code.

        auto res = MakeResult();
        res->ErrorTitle = STR_CANT_CHANGE_LAND_TYPE; // Always set the ErrorTitle even in a non error.
        // If you do not set the ErrorTitle then insufficient funds messages display (undefined string).
        res->ExpenditureType = RCT_EXPENDITURE_TYPE_LANDSCAPING;

        auto normRange = _range.Normalise();
        auto x0 = std::max(normRange.GetLeft(), 32);
        auto y0 = std::max(normRange.GetTop(), 32);
        auto x1 = std::min(normRange.GetRight(), (int32_t)gMapSizeMaxXY);
        auto y1 = std::min(normRange.GetBottom(), (int32_t)gMapSizeMaxXY);

        MapRange validRange{ x0, y0, x1, y1 };

        auto xMid = (validRange.GetLeft() + validRange.GetRight()) / 2 + 16;
        auto yMid = (validRange.GetTop() + validRange.GetBottom()) / 2 + 16;
        auto heightMid = tile_element_height(xMid, yMid) & 0xFFFF;

        res->Position.x = xMid;
        res->Position.y = yMid;
        res->Position.z = heightMid;

At this point I realised that the surfaceStyle and edgeStyle have not been checked for validity. From looking at its use surfaceStyle is invalid if its value is >0x1F and it must also be a loaded object. We should modify the function to check for this to prevent crashes and abuse. edgeStyle is invalid if >0xF it should also verify that there is an object tied to this entry. For both of these styles 0xFF is used to indicate that the style should not be changed.

Additional checking code.

        auto& objManager = GetContext()->GetObjectManager();
        if (_surfaceStyle != 0xFF)
        {
            if (_surfaceStyle > 0x1F)
            {
                log_error("Invalid surface style.");
                return MakeResult(GA_ERROR::INVALID_PARAMETERS, STR_CANT_CHANGE_LAND_TYPE);
            }

            const auto surfaceObj = static_cast<TerrainSurfaceObject*>(
                objManager.GetLoadedObject(OBJECT_TYPE_TERRAIN_SURFACE, _surfaceStyle));

            if (surfaceObj == nullptr)
            {
                log_error("Invalid surface style.");
                return MakeResult(GA_ERROR::INVALID_PARAMETERS, STR_CANT_CHANGE_LAND_TYPE);
            }
        }

        if (_edgeStyle != 0xFF)
        {
            if (_edgeStyle > 0xF)
            {
                log_error("Invalid edge style.");
                return MakeResult(GA_ERROR::INVALID_PARAMETERS, STR_CANT_CHANGE_LAND_TYPE);
            }

            const auto edgeObj = static_cast<TerrainEdgeObject*>(
                objManager.GetLoadedObject(OBJECT_TYPE_TERRAIN_SURFACE, _edgeStyle));

            if (edgeObj == nullptr)
            {
                log_error("Invalid edge style.");
                return MakeResult(GA_ERROR::INVALID_PARAMETERS, STR_CANT_CHANGE_LAND_TYPE);
            }
        }

We can now confirm that all of the parameters are set up.

Verify Permissions

Original code:

        // Do nothing during pause
        if (game_is_paused() && !gCheatsBuildInPauseMode) // This is no longer required
        {
            return 0;
        }

        // Do nothing if not in editor, sandbox mode or landscaping is forbidden
        if (!(gScreenFlags & SCREEN_FLAGS_SCENARIO_EDITOR) && !gCheatsSandboxMode
            && (gParkFlags & PARK_FLAGS_FORBID_LANDSCAPE_CHANGES))
        {
            return 0; // This is not correct it should return an error message
        }

We can straight away delete the pause check that is handled differently in GameActions. The second part can be fixed and return a valid error message.

        // Do nothing if not in editor, sandbox mode or landscaping is forbidden
        if (!(gScreenFlags & SCREEN_FLAGS_SCENARIO_EDITOR) && !gCheatsSandboxMode
            && (gParkFlags & PARK_FLAGS_FORBID_LANDSCAPE_CHANGES))
        {
            return MakeResult(GA_ERROR::DISALLOWED, STR_CANT_CHANGE_LAND_TYPE, STR_FORBIDDEN_BY_THE_LOCAL_AUTHORITY);
        }

The Rest of the Owl

The rest of the function is hard to follow if broken up so consider it as a whole. In an ideal world this would be split up into multiple functions.

        money32 surfaceCost = 0;
        money32 edgeCost = 0;
        for (int32_t x = x0; x <= x1; x += 32)
        {
            for (int32_t y = y0; y <= y1; y += 32)
            {
                if (x > 0x1FFF) // This should never happen as range is already verified and normalised
                    continue;
                if (y > 0x1FFF)
                    continue;

                if (!(gScreenFlags & SCREEN_FLAGS_SCENARIO_EDITOR) && !gCheatsSandboxMode)
                {
                    if (!map_is_location_in_park({ x, y }))
                        continue; // This function actually checks the range again just to be really sure
                }

                auto surfaceElement = map_get_surface_element_at({ x, y })->AsSurface();
// This is dangerous potential null deref. Except we have triple checked range is valid.
                if (surfaceElement == nullptr)
                {
                    continue;
                }

                if (surfaceStyle != 0xFF)
                {
                    uint8_t cur_terrain = surfaceElement->GetSurfaceStyle(); // names could be improved

                    if (surfaceStyle != cur_terrain)
                    {
                        // Prevent network-originated value of surfaceStyle from causing
                        // invalid access. **now better protected**
                        uint8_t style = surfaceStyle & 0x1F;
                        auto& objManager = GetContext()->GetObjectManager();
                        const auto surfaceObj = static_cast<TerrainSurfaceObject*>(
                            objManager.GetLoadedObject(OBJECT_TYPE_TERRAIN_SURFACE, style));
                        if (surfaceObj != nullptr)
                        {
                            surfaceCost += surfaceObj->Price;
                        }

                        if (flags & GAME_COMMAND_FLAG_APPLY) // In the query this can all be removed
                        {
                            surfaceElement->SetSurfaceStyle(surfaceStyle);

                            map_invalidate_tile_full(x, y);
                            footpath_remove_litter(x, y, tile_element_height(x, y));
                        }
                    }
                }

                if (edgeStyle != 0xFF)
                {
                    uint8_t currentEdge = surfaceElement->GetEdgeStyle();

                    if (edgeStyle != currentEdge)
                    {
                        edgeCost += 100;

                        if (flags & GAME_COMMAND_FLAG_APPLY) // In the query this can all be removed
                        {
                            surfaceElement->SetEdgeStyle(edgeStyle);
                            map_invalidate_tile_full(x, y);
                        }
                    }
                }

                if (flags & GAME_COMMAND_FLAG_APPLY) // In the query this can all be removed
                {
                    if (surfaceElement->CanGrassGrow() && (surfaceElement->GetGrassLength() & 7) != GRASS_LENGTH_CLEAR_0)
                    {
                        surfaceElement->SetGrassLength(GRASS_LENGTH_CLEAR_0);
                        map_invalidate_tile_full(x, y);
                    }
                }
            }
        }
        // This is no longer performed this way in GameActions and can be deleted.
        if (flags & GAME_COMMAND_FLAG_APPLY && gGameCommandNestLevel == 1)
        {
            LocationXYZ16 coord;
            coord.x = ((x0 + x1) / 2) + 16;
            coord.y = ((y0 + y1) / 2) + 16;
            coord.z = tile_element_height(coord.x, coord.y);
            network_set_player_last_action_coord(network_get_player_index(game_command_playerid), coord);
        }
        // No requirement for NO_MONEY check in GameActions
        return (gParkFlags & PARK_FLAGS_NO_MONEY) ? 0 : surfaceCost + edgeCost;

New code

        money32 surfaceCost = 0;
        money32 edgeCost = 0;
        for (int32_t x = validRange.GetLeft(); x <= validRange.GetRight(); x += 32)
        {
            for (int32_t y = validRange.GetTop(); y <= validRange.GetBottom(); y += 32)
            {
                if (!(gScreenFlags & SCREEN_FLAGS_SCENARIO_EDITOR) && !gCheatsSandboxMode)
                {
                    if (!map_is_location_in_park({ x, y }))
                        continue;
                }

                auto tileElement = map_get_surface_element_at({ x, y });
                if (tileElement == nullptr)
                {
                    continue;
                }

                auto surfaceElement = tileElement->AsSurface();
                if (_surfaceStyle != 0xFF)
                {
                    uint8_t curSurfaceStyle = surfaceElement->GetSurfaceStyle();

                    if (_surfaceStyle != curSurfaceStyle)
                    {
                        auto& objManager = GetContext()->GetObjectManager();
                        const auto surfaceObj = static_cast<TerrainSurfaceObject*>(
                            objManager.GetLoadedObject(OBJECT_TYPE_TERRAIN_SURFACE, _surfaceStyle));
                        if (surfaceObj != nullptr)
                        {
                            surfaceCost += surfaceObj->Price;
                        }
                    }
                }

                if (_edgeStyle != 0xFF)
                {
                    uint8_t curEdgeStyle = surfaceElement->GetEdgeStyle();

                    if (_edgeStyle != curEdgeStyle)
                    {
                        edgeCost += 100;
                    }
                }
            }
        }
        res->Cost = surfaceCost + edgeCost;

        return res;

At this point the Query is now complete. Further refactoring could look at splitting it up into separate functions.

Execute Function

Within the execute function we do not need to check for validity of parameters. But we do need to do the parts that were guarded by GAME_COMMAND_FLAG_APPLY.

The start of the function is pretty similar:

        auto res = MakeResult();
        res->ErrorTitle = STR_CANT_CHANGE_LAND_TYPE;
        res->ExpenditureType = RCT_EXPENDITURE_TYPE_LANDSCAPING;

        auto normRange = _range.Normalise();
        auto x0 = std::max(normRange.GetLeft(), 32);
        auto y0 = std::max(normRange.GetTop(), 32);
        auto x1 = std::min(normRange.GetRight(), (int32_t)gMapSizeMaxXY);
        auto y1 = std::min(normRange.GetBottom(), (int32_t)gMapSizeMaxXY);

        MapRange validRange{ x0, y0, x1, y1 };

        auto xMid = (validRange.GetLeft() + validRange.GetRight()) / 2 + 16;
        auto yMid = (validRange.GetTop() + validRange.GetBottom()) / 2 + 16;
        auto heightMid = tile_element_height(xMid, yMid) & 0xFFFF;

        res->Position.x = xMid;
        res->Position.y = yMid;
        res->Position.z = heightMid;

We can then go into the main for loop.

        money32 surfaceCost = 0;
        money32 edgeCost = 0;
        for (int32_t x = validRange.GetLeft(); x <= validRange.GetRight(); x += 32)
        {
            for (int32_t y = validRange.GetTop(); y <= validRange.GetBottom(); y += 32)
            {
                auto tileElement = map_get_surface_element_at({ x, y });
                if (tileElement == nullptr) // Nullptr checks should always be kept
                {
                    continue;
                }

                auto surfaceElement = tileElement->AsSurface();
                if (_surfaceStyle != 0xFF)
                {
                    uint8_t curSurfaceStyle = surfaceElement->GetSurfaceStyle();

                    if (_surfaceStyle != curSurfaceStyle)
                    {
                        auto& objManager = GetContext()->GetObjectManager();
                        const auto surfaceObj = static_cast<TerrainSurfaceObject*>(
                            objManager.GetLoadedObject(OBJECT_TYPE_TERRAIN_SURFACE, _surfaceStyle));
                        if (surfaceObj != nullptr)
                        {
                            surfaceCost += surfaceObj->Price;

                            surfaceElement->SetSurfaceStyle(_surfaceStyle);

                            map_invalidate_tile_full(x, y);
                            footpath_remove_litter(x, y, tile_element_height(x, y));
                        }
                    }
                }

                if (_edgeStyle != 0xFF)
                {
                    uint8_t curEdgeStyle = surfaceElement->GetEdgeStyle();

                    if (_edgeStyle != curEdgeStyle)
                    {
                        edgeCost += 100;

                        surfaceElement->SetEdgeStyle(_edgeStyle);
                        map_invalidate_tile_full(x, y);
                    }
                }

                if (surfaceElement->CanGrassGrow() && (surfaceElement->GetGrassLength() & 7) != GRASS_LENGTH_CLEAR_0)
                {
                    surfaceElement->SetGrassLength(GRASS_LENGTH_CLEAR_0);
                    map_invalidate_tile_full(x, y);
                }
            }
        }
        res->Cost = surfaceCost + edgeCost;

        return res;

The execute function has now been completed. There is a lot of overlap between the two functions and it may make more sense to combine the function into one again. We are now ready to start moving the three calls to the game command into three calls to the GameAction.

Modify Callers

Now that the game action is setup we can modify the callers to use the new class. Start with just one and test it before rolling it out to all of the others.

                gGameCommandErrorTitle = STR_CANT_CHANGE_LAND_TYPE; // This is no longer used.
                game_do_command(
                    gMapSelectPositionA.x, 1, gMapSelectPositionA.y, gLandToolTerrainSurface | (gLandToolTerrainEdge << 8),
                    GAME_COMMAND_CHANGE_SURFACE_STYLE, gMapSelectPositionB.x, gMapSelectPositionB.y);
                gCurrentToolId = TOOL_UP_DOWN_ARROW; // This should maybe be wrapped in a callback

New code

                auto surfaceSetStyleAction = SurfaceSetStyleAction(
                    { gMapSelectPositionA.x, gMapSelectPositionA.y, gMapSelectPositionB.x, gMapSelectPositionB.y },
                    gLandToolTerrainSurface, gLandToolTerrainEdge);

                GameActions::Execute(&surfaceSetStyleAction);

                gCurrentToolId = TOOL_UP_DOWN_ARROW;

Test the function by first locally using it. If that is successful try it with a networked local server. When I first wrote this tutorial I forgot to implement the serialise function. This prevented networked play. The console helpfully told me that I was trying to use an invalid surface style which straight away informed me the parameter was not being sent correctly. During the testing I found there was no need for a callback on the ToolId.

Delete Old Game Command

Now the fun part of deleting all references to the old game command and its function. Remember to leave a note in Game.h that the GAME_COMMAND has been turned into a GameAction by leaving a "GA" comment.

Clone this wiki locally