Alessandro Cuzzocrea

Reverse Engineering Therion's Steal Success Rate in Octopath Traveler

todo

So I was playing Octopath Traveler (wonderful game, btw) but I had a suspicion that the game was BS-ing me about the steal rates.

So I wanted to know exactly how the game decides whether Therion gets to steal an item.

This is about the field Steal Path Action (stealing from town NPCs), not the Thief job’s battle steal skills.

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

Table of Contents

TL;DR

DifferenceChance
11 or more100%
4 to 1080%
2 to 365%
0 to 155%
−1 to −315%
−4 to −108%
−11 or less3%

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.

Methodology

This is a static analysis of the game’s decompiled 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 (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 ITM_061 (Magic Nut (M)):

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.

Instead, steal difficulty (ProperSteal) is defined per entry in Content/Shop/Database/PurchaseItemTable.

Its row struct, PurchaseItemInfoData, is the same struct used by the steal code above.

The clearest example I found is the Magic Nut (M) . Both rows are from PurchaseItemTable:

rowItemLabelTown*NPC*FCPriceProperStealSuccess rate*
ForestL_SHOP_FC_18_3ITM_061Victors HollowTownsperson near the entrance2,28020100%
DesertL_SHOP_FC_25_2ITM_061MarsalimKnow-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. ForestL and DesertL correspond to Victors Hollow and Marsalim , respectively.

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.

This is why the parameter names alone are misleading: the probability buckets are spread across generic data fields, and the Blueprint determines which values to use.

RandomBoolWithWeight

The final step calls Unreal Engine’s Random Bool with Weight Blueprint node, exposed 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!

Putting it together

With the values shipped in the game:

FieldCommandLevel − ProperStealChanceSlot used
≥ 11100%STEAL_PROBABILITY_OVER → Init
≥ 480%STEAL_PROBABILITY_OVER → Max
≥ 265%STEAL_PROBABILITY_OVER → Min
0 or 155%STEAL_PROBABILITY_LESS_THAN → Param
≤ −115%STEAL_PROBABILITY_LESS_THAN → Init
≤ −48%STEAL_PROBABILITY_LESS_THAN → Max
≤ −113%STEAL_PROBABILITY_LESS_THAN → Min

A simple example

Let’s 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 falls into the 55% bucket instead.

This might be 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.

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