Warframe decipher – Corpus edition

This time we will implement automatic decipher. Warframe is a cooperative free-to-play third person online action game set in an evolving sci-fi world, similar to Destiny. There are 2 types of ciphers that we have to occasionally solve in order to complete an objective. Today we will focus on a Corpus faction cipher. Here is a sample one.

Our goal is to rotate hexagons so lines across all of pieces are connected. Each puzzle piece can be rotated clockwise (left click) or counter clockwise (right click). Here is sample of solved puzzle:

These ciphers are not complicated to solve, however there is always a time limit to crack it and sometimes time is of essence (especially when you have triggered an alarm in spy mission and have to complete 2 ciphers under 15 seconds with enemies shooting at you). In normal missions we can use automatic deciphers that can be built in game using small amount of resources. Unfortunately there are missions where using these auto solvers is prohibited. I personally am just lazy and wanted to create this simple automation as a quality of life improvement.

Definition

First off we need to define our cipher. This cipher has always the same layout, however I decided to feed my helper with layout data from configuration file.

-1:-1:1:3:2:-1 1040:204 1065:246 1119:196 1090:245 1157:259 1102:265 1113:334 1089:289 1036:338 1064:290 1000:273 1052:269
-1:-1:-1:4:3:0 1268:180 1297:225 1358:168 1326:221 1405:239 1341:245 1356:318 1325:270 1263:323 1296:271 1222:255 1282:249
-1:0:3:5:-1:-1 930:398 953:442 1003:397 978:439 1039:462 988:463 998:531 975:486 925:529 951:486 893:464 940:464
0:1:4:6:5:2 1144:388 1171:437 1228:385 1197:436 1270:455 1210:458 1226:531 1197:482 1139:531 1169:482 1099:460 1155:460
1:-1:-1:-1:6:3 1391:378 1425:429 1494:373 1457:427 1545:449 1473:452 1490:531 1455:478 1389:531 1424:477 1342:454 1409:453
2:3:6:-1:-1:-1 1027:589 1051:635 1105:587 1078:635 1145:661 1088:660 1101:733 1075:682 1022:729 1050:681 987:655 1038:658
3:4:-1:-1:-1:5 1257:590 1288:645 1348:593 1317:646 1398:674 1333:671 1349:752 1317:696 1255:743 1287:694 1213:665 1273:668

Every line describes single puzzle piece and contains IDs of neighbors and coordinates required to perform pattern sampling. Puzzles are defined in top to bottom, left to right order thus have following IDs (small numbers indicate neighbor IDs):

Neighbors are enumerated in following order (this order is kept for all neighbor/pattern related data across the code):

Each pair of coordinates defines single 'segment' (white line coming from center of piece). Here is how it looks like:

We have to use 2 points for each segment as sometimes it may be partially covered by other UI elements, like in the following example:

It won't guarantee proper detection in all weird cases but it will work 99% of the time.

Reading state

Before we proceed, here is a part of a puzzle piece class:

public class PuzzlePiece
{
    //...

    public bool Locked { get; private set; }
    public bool Empty { get; private set; }
    public int Rotations { get; private set; }
    public List<bool> Pattern = new List<bool>();
    public List<bool> OriginalPattern = new List<bool>();
    public List<int> Connections = new List<int>(); // 0 - no connection, 1 - potential connection, 2 - required connection
    public List<int> Neighbours = new List<int>();
    public List<Point> PatternTestPositions = new List<Point>();
    public Point Center = new Point(0, 0);
    static Color CIPHER_PATTERN_COLOR = Color.FromArgb(240, 240, 240);
}

Each puzzle has pattern defined as a list of 6 Boolean values stating whenever 'white line' is present or not. Having all above data defined, reading pattern for each puzzle is super simple. In order to improve reliability we will be looking for a pixel with certain color in proximity of specified coordinates.

public void Init(IntPtr screen_hbitmap)
{
    Rotations = 0;
    Locked = true;
    Empty = true;

    Pattern.Clear();

    for (int i = 0; i < 6; ++i)
    {
        bool present = false;

        for (int j = 0; j < 2; ++j)
        {
            int X_RADIUS = 3;
            int Y_RADIUS = 3;

            for (int y_diff = -Y_RADIUS; y_diff <= Y_RADIUS; ++y_diff)
            {
                for (int x_diff = -X_RADIUS; x_diff <= X_RADIUS; ++x_diff)
                {
                    Color pixel_color = AutoIt.PixelGetColor(screen_hbitmap, PatternTestPositions[2 * i + j].X + x_diff, PatternTestPositions[2 * i + j].Y + y_diff);
                    present |= AutoIt.IsColor(pixel_color, CIPHER_PATTERN_COLOR, 20);

                    if (present)
                        break;
                }
                
                if (present)
                    break;
            }
            
            if (present)
                break;
        }

        Pattern.Add(present);

        if (present)
        {
            Locked = false;
            Empty = false;
        }
    }

    OriginalPattern = new List<bool>(Pattern);
}

Next step is to figure out possible connections for each puzzle piece. This is simple as well. Connections is 6 element list storing connection 'status' value for each neighbor. Initially it can be only 1 (if there is non-empty puzzle piece as a neighbor) or 0 (otherwise). Later on when a given puzzle piece is locked in its rotation, his connections as well as neighboring pieces connections will be updated.

Solving puzzle

Overall idea is to go through all puzzle pieces until we can lock one of them and then repeat this process until all pieces are locked. Puzzle piece is considered locked when there is only one way in which its pattern can be rotated to match connections.

public bool IsMatching()
{
    for (int i = 0; i < 6; ++i)
    {
        if (((Connections[i] == 0 || Cipher.GetPuzzle(Neighbours[i]).Empty) && Pattern[i]) || // no connection or empty neigbour but pattern requests a connection
            (Connections[i] == 2 && !Pattern[i])) // required connection but no pattern match
        {
            return false;
        }
    }

    return true;
}

We can rotate pattern by performing kind of round robin shift by moving first element of pattern to the end.

public void RotateCW(int rotations = 1)
{
    if (Empty)
        return;

    for (int i = 0; i < rotations; ++i)
    {
        Pattern.Insert(0, Pattern[Pattern.Count - 1]);
        Pattern.RemoveAt(Pattern.Count - 1);
    }
}

When piece is lock we have to update his neighbors connections to reflect his final pattern rotation, thus limiting their number of matching rotations. Here is code for whole lock attempt process

public bool TryLock()
{
    Pattern = new List<bool>(OriginalPattern); // reset pattern

    bool wasMatching = false;

    for (int i = 0; i < 6; ++i)
    {
        if (IsMatching())
        {
            // cannot be locked when there are multiple matching rotation options
            if (wasMatching)
                return false;

            wasMatching = true;
            Rotations = i;
        }

        RotateCW();
    }

    Locked = wasMatching;

    if (Locked)
    {
        ResetRotation();
        RotateCW(Rotations);

        // go over neigbours and update their potential connections as this puzzle rotations is locked now
        // so some of the neighbours connections are no longer possible
        for (int i = 0; i < 6; ++i)
        {
            if (Neighbours[i] == -1)
                continue;

            PuzzlePiece neigbour = Cipher.GetPuzzle(Neighbours[i]);
            // for neighbor at index 0 this puzzle is described by connection at index 3
            // below we are using generic formula to calculate connection index for any neighbor
            neigbour.Connections[(i + 3) % 6] = Pattern[i] ? 2 : 0;
        }
    }

    return Locked;
}

Final touch optimization to applying our solution in game is to rotate counter clockwise when there are more that 3 rotations required.

int rotations = p.Rotations;
bool cw = true;

if (rotations > 3)
{
    rotations = (6 - rotations);
    cw = false;
}

Point center = p.GetCenter();

for (int r = 0; r < rotations; ++r)
{
    AutoIt.MouseClick(cw ? "left" : "right", center.X, center.Y, 1);
    AutoIt.Sleep(50 + AutoIt.Random(50));
}

That's pretty much it. Here is how it looks like in action.

Full source code is available for patrons on https://www.patreon.com/bottersgonnabot

2 thoughts on “Warframe decipher – Corpus edition”

Comments are closed.