Alessandro Cuzzocrea

Reverse Engineering Therion's Steal Success Rate in Octopath Traveler

Digging into the Blueprint logic behind Therion's Steal Path Action in Unreal Engine.

A screenshot from Octopath Traveler showing the snowy town of Northreach, where an NPC named Angela catches the player, featuring a large speech bubble that reads 'busted' in bright blue text

I was playing some Octopath Traveler (wonderful game, btw) when I started suspecting that its steal success rates were BS.

So I decided to spend some time digging into the game’s code to find out exactly how its Steal mechanic determines whether Therion successfully steals an item.

This is about Therion’s Steal Path Action (stealing from town NPCs) not using the Thief job’s Steal skill in battle.

🚨 DISCLAIMER 🚨

This is for understanding the game.
I don’t redistribute the disassembled code or assets.

Table of Contents

TL;DR

What exactly is the Steal Path Action?

Steal is Therion’s Path Action .

Basically, you walk up to an NPC, and the game lists the items they’re carrying, along with the success rate for stealing each one.

It usually draws from the same item pool as Tressa ’s Purchase , with a few exceptions: some items can’t be stolen at all, while others are flagged as not for sale, making them exclusive to Steal.

If you fail an attempt, your reputation in that town takes a hit.

A gameplay screenshot of Octopath Traveler in the snowy town of Northreach, displaying the UI prompt for Therion's Steal Path Action on the NPC Angela. Overlaid on the dark, snowing background, the menu shows Angela's possessions—including Joshua's Poem with a highlighted success rate of 3%, and a Magic Nut (L) at 8%. A confirmation box reads 'Joshua's Poem / Angela's Possessions / Chance of success: 3% / Attempt to steal? / (Your reputation in town will suffer if you fail.)' with selection buttons for 'Yes' and 'No'

Methodology

This is a static analysis of the game’s decompiled Unreal Engine’s Blueprint logic and data tables.

The game is Steam App ID 921570, build 5272616 (published 2020-07-22), running ++UE4+Release-4.18 as a cooked -WindowsNoEditor Shipping package.

The Blueprint bytecode is stored inside a roughly 2 GiB .pak file.

The .pak index is unencrypted (version 4, zlib, 64 KiB blocks), and FModel exports a clean .uasset + .uexp pair.

FModel can export the assets, but it doesn’t decompile Blueprint bytecode. For that, I used KismetKompiler 0.4.0-alpha.

The steal formula

FieldCommandPurchase_C.StealItemExec is where the decision is made.

Decompiled to pseudocode:

 1diff = Subtract_IntInt(this.FieldCommandLevel, PurchaseItemInfoData.ProperSteal);
 2
 3// Both getters fill four out-params from one DataTable row: (min, max, init, param)
 4
 5//   STEAL_PROBABILITY_LESS_THAN           3,   8,  15,  55    -> the low buckets
 6//   STEAL_PROBABILITY_OVER               65,  80, 100,   0    -> the high buckets
 7//   STEAL_PROBABILITY_LEVEL_OVER           2,   4,  11,   0    -> thresholds, high side
 8//   STEAL_PROBABILITY_LEVEL_LESS_THAN    -11,  -4,  -1,   0    -> thresholds, low side
 9
10if (diff == 0) {
11    GetGameParamToFloat("STEAL_PROBABILITY_LESS_THAN", this, out LessMin, out LessMax, out LessInit, out LessParam);
12    weight = LessParam;                              // 55
13}
14else if (diff > 0) {
15    GetGameParamToInt  ("STEAL_PROBABILITY_LEVEL_OVER", this, out LvMin, out LvMax, out LvInit, out LvParam);
16    GetGameParamToFloat("STEAL_PROBABILITY_OVER",       this, out OverMin, out OverMax, out OverInit, out OverParam);
17    if      (diff >= LvInit) weight = OverInit;      // 100   (LvInit = 11)
18    else if (diff >= LvMax)  weight = OverMax;       // 80    (LvMax = 4)
19    else if (diff >= LvMin)  weight = OverMin;       // 65    (LvMin = 2)
20    else {
21        GetGameParamToFloat("STEAL_PROBABILITY_LESS_THAN", this, out LessMin, out LessMax, out LessInit, out LessParam);
22        weight = LessParam;                          // 55    (diff == 1: no high bucket matched)
23    }
24}
25else {
26    GetGameParamToInt  ("STEAL_PROBABILITY_LEVEL_LESS_THAN", this, out LvMin, out LvMax, out LvInit, out LvParam);
27    GetGameParamToFloat("STEAL_PROBABILITY_LESS_THAN",       this, out LessMin, out LessMax, out LessInit, out LessParam);
28    if      (diff <= LvMin)  weight = LessMin;       // 3     (LvMin = -11)
29    else if (diff <= LvMax)  weight = LessMax;       // 8     (LvMax = -4)
30    else if (diff <= LvInit) weight = LessInit;      // 15    (LvInit = -1)
31    // else: unreachable — every diff < 0 already satisfied diff <= -1
32}
33
34// and finally, the roll:
35weight01 = FMin(weight * 0.01f, 1f);
36this.SuccessSteal = RandomBoolWithWeight(weight01);

FieldCommandLevel

FieldCommandLevel is not Therion’s raw level:

1// FieldCommandPurchase_C.Open(NPCLabel, StealMode)
2if (StealMode) {
3    LibFieldCommand_C.GetFieldCommandLevelFromType(2, true, this, out this.FieldCommandLevel);   // (byte)2 = eSteal
4} else {
5    LibFieldCommand_C.GetFieldCommandLevelFromType(1, true, this, out this.FieldCommandLevel);   // (byte)1 = ePurchase
6}

The field-command level for command type 2 is KSFIeldCommandType::eSteal.

The CheckFCItem=True argument is what enables the Thieving Tips & Tricks bonus lookup.

The decompiled function finds the traveler whose PlayableCharacterDB row matches the command type, reads that character’s level from the save, then adds the bonus and clamps the result to 99.

99 is the number of rows in CharacterGrowData, which has one row per level, named 1 through 99.

 1static public void GetFieldCommandLevelFromType(byte CommandType, bool CheckFCItem, out int FieldCommandLevel, Object __WorldContext) {
 2    int CharacterLevel = 0;
 3    int TmpFieldCommandLevel;
 4
 5    // which traveler owns this command? Therion for eSteal
 6    Array<Name> RowNames;
 7    GetDataTableRowNames(PlayableCharacterDB, out RowNames);
 8    for (int i = 0; i < RowNames.Num(); i++) {
 9        PlayableCharacterData Row;
10        if (!GetDataTableRowFromName(PlayableCharacterDB, RowNames[i], out Row)) continue;
11        if (Row.FieldCommandType != CommandType) continue;
12
13        // their level, straight out of the save
14        KSSaveGameBP_C SaveGame;
15        GetSaveData(__WorldContext, out SaveGame);
16        SaveCharacterData CharacterData;
17        SaveGame.GetCharacterData(Row.ID, out CharacterData);
18        CharacterLevel = CharacterData.Level;
19        break;
20    }
21
22    TmpFieldCommandLevel = CharacterLevel;
23
24    if (CheckFCItem && CommandType != 8 && CommandType != 7 && CommandType != 4) {   // not scrutinize / inquire / provoke
25        bool FindItem;
26        int Value;
27        CheckHaveFieldCommandItem(CommandType, __WorldContext, out FindItem, out Value);
28        if (FindItem) {
29            Array<Name> GrowRowNames;
30            GetDataTableRowNames(CharacterGrowData, out GrowRowNames);
31            TmpFieldCommandLevel = Min(TmpFieldCommandLevel + Value, GrowRowNames.Num());
32        }
33    }
34
35    FieldCommandLevel = TmpFieldCommandLevel;
36}

CheckHaveFieldCommandItem

CheckHaveFieldCommandItem checks whether the field-command bonus applies in the current town.

In gameplay terms, this is the Thieving Tips & Tricks bonus. You obtain it by using Cyrus’s Inquire or Alfyn’s Scrutinize on certain NPCs. It improves Therion’s stealing success rate, but only in the town where you obtained the information.

The game implements this by adding a town-specific bonus to Therion’s effective field-command level. The bonus changes the level difference directly, which determines which of the seven probability buckets applies.

8 of the 28 towns have a Steal entry, each specifying the item associated with its bonus:

townitembonus
T_Snow_MINFO_SNO_TMA_003+10
T_Plain_MINFO_PLA_TMA_001+10
T_Sea_LINFO_SEA_TLA_001+30
T_Mount_MINFO_MOU_TMC_002+10
T_Desert_SINFO_DES_TS_002+10
T_Desert_LINFO_DES_TLA_004+10
T_River_MINFO_RIV_TMB_004+10
T_Cliff_LINFO_CLI_TLA_003+10

ProperSteal

The item definitions in Content/Item/Database/ItemDB (struct ItemData) contain fields such as ItemID, BuyPrice, SellPrice, category and attributes, but no steal-difficulty field.

For example, here’s the relevant part of the ItemData record for Magic Nut (M) , identified as ITM_061:

FieldValue
ItemID73
ItemNameIDTX_NA_ITM_061
DetailTextIDTX_CO_ITM_061
BuyPrice2,400
SellPrice240

I’ve omitted the other fields, which cover things like item categories, targeting, attributes, resistances and icons.

So where is the steal difficulty stored?

It’s defined in Content/Shop/Database/PurchaseItemTable, whose row struct, PurchaseItemInfoData, is also used by the steal code.

Magic Nut (M) makes this super clear because the same item appears in two rows of PurchaseItemTable, with different ProperSteal values:

RowItemLabelTown*NPC*FCPriceProperStealSuccess rate*
ForestL_SHOP_FC_18_3ITM_061Victors Hollow Townsperson near the entrance2,28020100%
DesertL_SHOP_FC_25_2ITM_061Marsalim Know-it-all’s Father2,880443%

The Town, NPC and Success rate columns are my annotations for convenience; the original data identifies these entries by their row names.

Same item ID, different price and steal difficulty. With my Therion at level 32, the two ProperSteal values produce success rates of 100% and 3%, respectively.

Comparison of Therion's Steal success rates for a Magic Nut (M) in Victors Hollow and Marsalim

GameParamDefineTable

Content/GameParam/GameParamDefineTable is an Unreal Engine UDataTable with one row per parameter key. The row name is the key the Blueprint looks up, which is why parameter names such as STEAL_PROBABILITY_OVER appear as string literals in the bytecode.

The table contains various game settings, from backpack size and damage caps to encounter rates and UI timings. All entries share the same generic Min, Max, Init, and Param fields, even when a parameter doesn’t need all four.

The four steal-probability rows (86–89) use these fields as separate values:

RowNameTypeMinMaxInitParam
86STEAL_PROBABILITY_LESS_THANfloat381555
87STEAL_PROBABILITY_OVERfloat65801000
88STEAL_PROBABILITY_LEVEL_LESS_THANint-11-4-10
89STEAL_PROBABILITY_LEVEL_OVERint24110

For example, STEAL_PROBABILITY_LESS_THAN stores the four lower-side probabilities as 3, 8, 15, 55. The corresponding STEAL_PROBABILITY_LEVEL_LESS_THAN row supplies the thresholds -11, -4, -1.

The code selects a field based on the level difference and the relevant threshold. On the high side, STEAL_PROBABILITY_OVER supplies the three probabilities 65, 80, 100, while STEAL_PROBABILITY_LEVEL_OVER supplies the thresholds 2, 4, 11.

The probability buckets are spread across generic data fields, and the Blueprint logic determines which values to use.

RandomBoolWithWeight

The final step calls Unreal Engine’s RandomBoolWithWeight Blueprint node, implemented in C++ as UKismetMathLibrary::RandomBoolWithWeight.

1weight01 = FMin(weight * 0.01f, 1f);
2this.SuccessSteal = RandomBoolWithWeight(weight01);

The selected percentage is converted into a weight between 0 and 1. Like, a 55% success rate becomes 0.55. FMin caps the value at 1.0, ensuring it cannot exceed 100%.

The node then performs the weighted boolean roll, and its result is assigned directly to SuccessSteal.

I’m treating Unreal’s built-in random function as an ordinary, unbiased random roll here. I haven’t investigated its implementation, so I can’t guarantee that it has no bias or repeatable patterns. The UE source is available on GitHub, so that would make for a fun exploration, but maybe next time!

A gameplay screenshot of the HD-2D RPG Octopath Traveler, showing the protagonist party (including the white-haired Thief Therion) trying and failing a Steal attempt on an NPC named 'Old Man' in a sunlit, stone-built coastal town. The Old Man's dialogue bubble reads: 'You'll need to try harder than that, young'un!' in response to the unsuccessful Path Action, with historic-style houses, palm trees, and rocky cliffs visible in the background

Putting it together

The game maps the difference between Therion’s effective field-command level and ProperSteal to one of seven success rates like this:

FieldCommandLevel − ProperStealChanceParameter slot
≥ 11100%STEAL_PROBABILITY_OVER -> Init
4 to 1080%STEAL_PROBABILITY_OVER -> Max
2 to 365%STEAL_PROBABILITY_OVER -> Min
0 to 155%STEAL_PROBABILITY_LESS_THAN -> Param
−3 to −115%STEAL_PROBABILITY_LESS_THAN -> Init
−10 to −48%STEAL_PROBABILITY_LESS_THAN -> Max
≤ −113%STEAL_PROBABILITY_LESS_THAN -> Min

For example, say Therion is level 10 and has Thieving Tips & Tricks, which grants a +10 Steal bonus in the current town.

We’re trying to steal an item whose ProperSteal value is 10.

First, the game calculates Therion’s effective field-command level:

1FieldCommandLevel = Min(CharacterLevel + StealBonus, 99); // Min(10 + 10, 99) = 20

Then it subtracts the item’s steal difficulty:

1diff = FieldCommandLevel - ProperSteal; // 20 - 10 = 10

A difference of 10 falls into the diff >= 4 bucket, so the game selects STEAL_PROBABILITY_OVER.Max, which is 80%.

The resulting steal success rate is 80%.

Without Thieving Tips & Tricks, Therion’s effective level would be 10, giving a difference of 10 - 10 = 0. That would fall into the 55% bucket instead.

It’s kinda confusing, but the bonus doesn’t directly add 10 percentage points to the success rate. It increases Therion’s effective level, potentially moving him into a different probability bucket.

A gameplay screenshot from Octopath Traveler set in the snowy town of Northreach at night, during a successful steal by Therion. He stands in the snowy square surrounded by his party and an NPC, with heavy snow falling. A thought bubble above him reads: 'Therion: (Heh, I trust you won't be missing this...)' while the town's dimly lit, historic-style houses and pine trees are visible under the snowfall

Conclusion

Well, that concludes my little investigation.

Looks like the game was being honest after all.

Thanks for reading! Now, if you’ll excuse me, I’m going back to save-scumming my way to OP items. :P

Related Articles

Push Golf and the Unreal Engine 5 Petit Contest: Creating the Game (Part 2) - thumbnail Push Golf and the Unreal Engine 5 Petit Contest: Creating the Game (Part 2)
Push Golf and the Unreal Engine 5 Petit Contest: From Nostalgia to a Game Jam (Part 1) - thumbnail Push Golf and the Unreal Engine 5 Petit Contest: From Nostalgia to a Game Jam (Part 1)