Reverse Engineering Therion's Steal Success Rate in Octopath Traveler
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 it decides 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.
This is for understanding the game.
I don’t redistribute the disassembled code or assets.
Table of Contents
TL;DR
- The roll is a single call: RandomBoolWithWeight(min(percent * 0.01, 1)).
- The percent comes from a 7-bucket lookup based on effective field-command level − ProperSteal.
- Therion’s effective field-command level is his character level plus the Steal bonus from Thieving Tips & Tricks, if he has it, capped at 99.
- ProperStealis the steal difficulty assigned to the item for that specific NPC, rather than to the item itself.
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 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:
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:
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:
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.
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:
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!
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:
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.
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