Skip to content

Commit 5139ae8

Browse files
committed
feat(progress): add bulk support and NG+ roster unlocks
1 parent 508fcd7 commit 5139ae8

10 files changed

Lines changed: 155 additions & 13 deletions

File tree

‎CLI.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ The CLI patches only named, mapped fields. Unknown bytes are preserved. `inspect
2222

2323
`inheritance` reads the separate NG+ journal history: professor rank, 270 historical support-point values, 45 records of 11 skill ranks, and 45 class-mastery bitsets. These are not the current-run values. Each support entry also reports its game's `maximumRank`; `catalog --type supports` lists the available conversation ranks per pair. Character names follow the game's character database order; records without a confirmed playable character keep their numeric `recordIndex`. Support names come from the game's support table.
2424

25-
NG+ history can be edited with `setNgPlusProfessorRank` (`rank`: 0–9), `setNgPlusSupport` (`index`, `points`: 0–65535), `setNgPlusSkillRank` (`recordIndex`: 0–44, `skill`: 0–10, `rank`: 0–11), and `setNgPlusClassMastery` (`recordIndex`, `classId`: 0–99, `mastered`: boolean). `unlockNgPlusClasses` (`recordIndex`) sets only the character's eligible class-mastery bits, including DLC classes; existing bits for other classes are left untouched. For example, `{ "op": "setNgPlusSkillRank", "recordIndex": 0, "skill": 0, "rank": 11 }` changes the first historical character's sword rank. These operations edit historical unlocks, not the current-run character. The storage range for support points is known, but not every numeric value is meaningful to the game. Test one targeted change on a disposable save before broader edits.
25+
NG+ history can be edited with `setNgPlusProfessorRank` (`rank`: 0–9), `setNgPlusSupport` (`index`, `points`: 0–65535), `setNgPlusSkillRank` (`recordIndex`: 0–44, `skill`: 0–10, `rank`: 0–11), and `setNgPlusClassMastery` (`recordIndex`, `classId`: 0–99, `mastered`: boolean). `unlockNgPlusClasses` (`recordIndex`) sets only the character's eligible class-mastery bits, including DLC classes; existing bits for other classes are left untouched. `unlockNgPlusRoster` needs no index and maxes the skills and eligible class mastery of every playable character. For example, `{ "op": "setNgPlusSkillRank", "recordIndex": 0, "skill": 0, "rank": 11 }` changes the first historical character's sword rank. These operations edit historical unlocks, not the current-run character. Test one targeted change on a disposable save before broader edits.
2626

27-
For the GUI's rank controls, `setNgPlusSupportRank` and `setSupportRank` take a support `index` and one of that pair's available `rank` values. `maxSupportRank` and `maxNgPlusSupportRank` take an `index` and raise that pair's points to its highest conversation-rank threshold. `maxNgPlusSupports` needs no index and applies this to every valid inherited pair. These maximum operations leave already-higher hidden point values unchanged and skip invalid records in the bulk operation. Example: `{ "op": "maxNgPlusSupports" }`. They change point values, not independently stored conversation or ending events; back up and test a copy in-game. `setProfessorRank` takes a rank index from 0 (E) to 9 (A+) and writes the matching current-run instruction-experience threshold. Unlike `setNgPlusProfessorRank`, it does not edit inherited progress.
27+
For the GUI's rank controls, `setNgPlusSupportRank` and `setSupportRank` take a support `index` and one of that pair's available `rank` values. `maxSupportRank` and `maxNgPlusSupportRank` take an `index` and raise that pair's points to its highest conversation-rank threshold. `maxSupports` and `maxNgPlusSupports` need no index and apply this to every valid current-run or inherited pair, respectively. These maximum operations leave already-higher hidden point values unchanged and skip invalid records in the bulk operation. Example: `{ "op": "maxNgPlusSupports" }`. They change point values, not independently stored conversation or ending events; back up and test a copy in-game. `setProfessorRank` takes a rank index from 0 (E) to 9 (A+) and writes the matching current-run instruction-experience threshold. Unlike `setNgPlusProfessorRank`, it does not edit inherited progress.
2828

2929
## Edit
3030

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ Edit Fire Emblem: Three Houses saves, including inherited New Game+ progress.
1111
- **Roster:** Edit character stats, equipped abilities and combat arts, held items, skill experience, learned magic, flags, class experience and mastery, unlocked abilities and combat arts, and import or export individual characters.
1212
- **Battalions:** Change battalion type, assigned character, experience, stamina, and skill; sort the list or fill missing battalions from the curated collection.
1313
- **Quests:** Search quests and change their state.
14-
- **Support:** Edit the current run's support ranks and underlying points.
14+
- **Support:** Edit the current run's support ranks and underlying points, or raise every pair to its available maximum.
1515
- **Database:** Look up characters, classes, and items without changing the save.
16-
- **NG+ Roster:** Edit inherited skill ranks and class mastery for each character, or unlock them in bulk.
17-
- **NG+ Support:** Edit inherited support ranks and points separately from the current run.
16+
- **NG+ Roster:** Edit inherited skill ranks and class mastery for each character, or unlock every playable character in bulk.
17+
- **NG+ Support:** Edit inherited support ranks and points separately from the current run, or raise every pair to its available maximum.
1818
- **System:** Edit save-slot and unlock flags in the system save found alongside the selected slot.
1919

2020
Switch languages from the top menu. The interface and game-data names use the selected language where translations are available.

‎cli/Program.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,12 @@ private static void ApplyOperations(SaveBuffer save, Dictionary<string, object>
222222
case "maxNgPlusSupports":
223223
save.Inheritance.ReachMaxSupportRanks();
224224
break;
225+
case "maxSupports":
226+
save.ReachMaxSupportRanks();
227+
break;
228+
case "unlockNgPlusRoster":
229+
save.Inheritance.UnlockAllPlayableSkillsAndClasses();
230+
break;
225231
case "setNgPlusSkillRank":
226232
save.Inheritance.SetSkillRank(Value<int>(item, "recordIndex"), Value<int>(item, "skill"),
227233
Value<int>(item, "rank"));

‎core/NgPlusJournal.cs‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public sealed class NgPlusJournal
1111
{
1212
public const int CharacterCount = 45;
1313
public const int SkillCount = 11;
14+
public const int MaxSkillRank = 11;
1415
public const int ClassCount = 100;
1516
private const int ProfessorOffset = 0x17CE;
1617
private const int SupportOffset = 0x1576;
@@ -88,18 +89,24 @@ public void ReachMaxSupportRank(int index)
8889
SetSupportPoints(index, Math.Max(GetSupportPoints(index), target));
8990
}
9091

91-
public void ReachMaxSupportRanks()
92+
public int ReachMaxSupportRanks()
9293
{
94+
int updated = 0;
9395
for (int index = 0; index < Player_V23.COUNT_SUPPORT; index++)
94-
if (SupportPairRanks.MaxRank(index) != "None")
95-
ReachMaxSupportRank(index);
96+
{
97+
if (SupportPairRanks.MaxRank(index) == "None"
98+
|| GetSupportPoints(index) >= SupportPairRanks.MaxRankPoints(index)) continue;
99+
ReachMaxSupportRank(index);
100+
updated++;
101+
}
102+
return updated;
96103
}
97104

98105
public void SetSkillRank(int recordIndex, int skillIndex, int rank)
99106
{
100107
CheckIndex(recordIndex, CharacterCount, nameof(recordIndex));
101108
CheckIndex(skillIndex, SkillCount, nameof(skillIndex));
102-
if (rank < 0 || rank > 11) throw new ArgumentOutOfRangeException(nameof(rank));
109+
if (rank < 0 || rank > MaxSkillRank) throw new ArgumentOutOfRangeException(nameof(rank));
103110
file[player + SkillOffset + recordIndex * SkillCount + skillIndex] = (byte)rank;
104111
}
105112

@@ -119,6 +126,29 @@ public void UnlockAvailableClasses(int recordIndex)
119126
SetClassMastered(recordIndex, classId, true);
120127
}
121128

129+
public int UnlockAllPlayableSkillsAndClasses()
130+
{
131+
int updated = 0;
132+
for (int record = 0; record < CharacterCount; record++)
133+
{
134+
if (!ClassEligibility.IsPlayableRecord(record)) continue;
135+
for (int skill = 0; skill < SkillCount; skill++)
136+
{
137+
if (GetSkillRank(record, skill) >= MaxSkillRank) continue;
138+
SetSkillRank(record, skill, MaxSkillRank);
139+
updated++;
140+
}
141+
for (int classId = 0; classId < ClassCount; classId++)
142+
{
143+
if (!ClassEligibility.IsAvailable(record, classId)
144+
|| IsClassMastered(record, classId)) continue;
145+
SetClassMastered(record, classId, true);
146+
updated++;
147+
}
148+
}
149+
return updated;
150+
}
151+
122152
private (int offset, byte mask) ClassBit(int recordIndex, int classId)
123153
{
124154
CheckIndex(recordIndex, CharacterCount, nameof(recordIndex));

‎core/SaveBuffer.cs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,20 @@ public void ReachMaxSupportRank(int index)
7474
Set(path, Math.Max((long)Get(path), target));
7575
}
7676

77+
public int ReachMaxSupportRanks()
78+
{
79+
int updated = 0;
80+
for (int index = 0; index < Player_V23.COUNT_SUPPORT; index++)
81+
{
82+
if (SupportPairRanks.MaxRank(index) == "None") continue;
83+
string path = $"Player.CharacterSupportValues[{index}]";
84+
if ((long)Get(path) >= SupportPairRanks.MaxRankPoints(index)) continue;
85+
ReachMaxSupportRank(index);
86+
updated++;
87+
}
88+
return updated;
89+
}
90+
7791
public object Get(string path)
7892
{
7993
if (path.Equals("playerName", StringComparison.OrdinalIgnoreCase))

‎gui/Localization/zh_hans.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@
119119
"Inherited support rank": "可继承的支援等级",
120120
"Maximum rank:": "最高等级:",
121121
"Reach maximum rank": "达到最高等级",
122+
"Reach All Maximum Ranks": "全部达到最高等级",
123+
"Unlock All Skills & Classes": "解锁所有角色技能与兵种精通",
122124
"None": "无",
123125
"Search...": "搜索…",
124126
"Load a version-23 slot or auto save to begin.": "请先打开 1.2.0 版本的 slot 或 auto 存档。",

‎gui/MainWindow.CurrentSupports.cs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ private void MaxCurrentSupport_Click(object? sender, RoutedEventArgs e)
6060
}
6161
}
6262

63+
private void MaxAllCurrentSupports_Click(object? sender, RoutedEventArgs e)
64+
{
65+
if (_save is null) return;
66+
try
67+
{
68+
int updated = _save.ReachMaxSupportRanks();
69+
if (updated == 0) return;
70+
RefreshCurrentSupports(SelectedSourceIndex(CurrentSupportList));
71+
MarkChanged();
72+
}
73+
catch (Exception error)
74+
{
75+
Status.Text = error.Message;
76+
}
77+
}
78+
6379
private void SetCurrentSupport_Click(object? sender, RoutedEventArgs e)
6480
{
6581
int index = SelectedSourceIndex(CurrentSupportList);

‎gui/MainWindow.axaml‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -440,12 +440,14 @@
440440
<TabItem Name="SupportTab" Header="Support">
441441
<Grid Margin="8" ColumnDefinitions="320,*" ColumnSpacing="10">
442442
<suki:GlassCard Name="SupportListCard" Classes="section" Grid.Column="0">
443-
<Grid RowDefinitions="Auto,Auto,*" RowSpacing="8">
443+
<Grid RowDefinitions="Auto,Auto,*,Auto" RowSpacing="8">
444444
<TextBlock Text="Support List" FontWeight="SemiBold" />
445445
<TextBox Classes="Small" Name="CurrentSupportSearch" Grid.Row="1" PlaceholderText="Search..."
446446
TextChanged="SearchableList_TextChanged" />
447447
<ListBox Classes="Small" Name="CurrentSupportList" Grid.Row="2"
448448
SelectionChanged="CurrentSupportList_SelectionChanged" />
449+
<Button Classes="Small" Name="MaxAllCurrentSupportsButton" Grid.Row="3"
450+
Content="Reach All Maximum Ranks" Click="MaxAllCurrentSupports_Click" />
449451
</Grid>
450452
</suki:GlassCard>
451453
<suki:GlassCard Name="SupportEditorCard" Classes="section" Grid.Column="1">
@@ -515,22 +517,24 @@
515517
<TabItem Name="InheritanceCharactersTab" Header="NG+ Roster">
516518
<Grid Margin="8" ColumnDefinitions="264,*" ColumnSpacing="10">
517519
<suki:GlassCard Name="InheritanceCharacterListCard" Classes="section" Grid.Column="0">
518-
<Grid RowDefinitions="Auto,*" RowSpacing="12">
520+
<Grid RowDefinitions="Auto,*,Auto" RowSpacing="12">
519521
<TextBlock Text="Character List" FontWeight="SemiBold" />
520522
<Grid Grid.Row="1" RowDefinitions="Auto,*" RowSpacing="5">
521523
<TextBox Classes="Small" Name="CharacterSearch" PlaceholderText="Search..."
522524
TextChanged="CharacterSearch_TextChanged" />
523525
<ListBox Classes="Small" Name="CharacterList" Grid.Row="1"
524526
SelectionChanged="CharacterList_SelectionChanged" />
525527
</Grid>
528+
<Button Classes="Small" Name="UnlockAllRosterButton" Grid.Row="2"
529+
Content="Unlock All Skills &amp; Classes" Click="UnlockAllRoster_Click" />
526530
</Grid>
527531
</suki:GlassCard>
528532

529533
<suki:GlassCard Name="InheritanceCharacterEditorCard" Classes="section" Grid.Column="1">
530534
<Grid>
531535
<TabControl Name="InheritanceTabs">
532536
<TabItem Name="InheritedSkillsTab" Header="Skills">
533-
<StackPanel Margin="0,8,0,0" VerticalAlignment="Top">
537+
<StackPanel Margin="0,20,0,0" VerticalAlignment="Top">
534538
<UniformGrid Name="SkillRows" Columns="2" MaxWidth="760" HorizontalAlignment="Center" />
535539
</StackPanel>
536540
</TabItem>
@@ -560,12 +564,14 @@
560564
<TabItem Name="InheritanceSupportTab" Header="NG+ Support">
561565
<Grid Margin="8" ColumnDefinitions="320,*" ColumnSpacing="10">
562566
<suki:GlassCard Name="InheritanceSupportListCard" Classes="section" Grid.Column="0">
563-
<Grid RowDefinitions="Auto,Auto,*" RowSpacing="8">
567+
<Grid RowDefinitions="Auto,Auto,*,Auto" RowSpacing="8">
564568
<TextBlock Text="Support List" FontWeight="SemiBold" />
565569
<TextBox Classes="Small" Name="SupportSearch" Grid.Row="1" PlaceholderText="Search..."
566570
TextChanged="SupportSearch_TextChanged" />
567571
<ListBox Classes="Small" Name="SupportList" Grid.Row="2"
568572
SelectionChanged="SupportList_SelectionChanged" />
573+
<Button Classes="Small" Name="MaxAllInheritedSupportsButton" Grid.Row="3"
574+
Content="Reach All Maximum Ranks" Click="MaxAllInheritedSupports_Click" />
569575
</Grid>
570576
</suki:GlassCard>
571577
<suki:GlassCard Name="InheritanceSupportEditorCard" Classes="section" Grid.Column="1">

‎gui/MainWindow.axaml.cs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,22 @@ private void UnlockInheritedClasses_Click(object? sender, RoutedEventArgs e)
484484
MarkChanged();
485485
}
486486

487+
private void UnlockAllRoster_Click(object? sender, RoutedEventArgs e)
488+
{
489+
if (_save is null) return;
490+
try
491+
{
492+
int updated = _save.Inheritance.UnlockAllPlayableSkillsAndClasses();
493+
if (updated == 0) return;
494+
ShowCharacter();
495+
MarkChanged();
496+
}
497+
catch (Exception error)
498+
{
499+
Status.Text = error.Message;
500+
}
501+
}
502+
487503
private void ShowClasses()
488504
{
489505
ClassRows.Children.Clear();
@@ -579,6 +595,22 @@ private void MaxInheritedSupport_Click(object? sender, RoutedEventArgs e)
579595
}
580596
}
581597

598+
private void MaxAllInheritedSupports_Click(object? sender, RoutedEventArgs e)
599+
{
600+
if (_save is null) return;
601+
try
602+
{
603+
int updated = _save.Inheritance.ReachMaxSupportRanks();
604+
if (updated == 0) return;
605+
RefreshSupports();
606+
MarkChanged();
607+
}
608+
catch (Exception error)
609+
{
610+
Status.Text = error.Message;
611+
}
612+
}
613+
582614
private void ApplySupport_Click(object? sender, RoutedEventArgs e)
583615
{
584616
if (_save is null || _selectedSupport < 0) return;

‎tools/test_cli.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,42 @@ def main() -> None:
178178
assert max_history[62]["maxPoints"] == 801
179179
assert max_history[65]["maxPoints"] == 301
180180

181+
bulk_patch = directory / "bulk-unlocks.json"
182+
bulk_patch.write_text(json.dumps({"operations": [
183+
{"op": "set", "path": "Player.CharacterSupportValues[0]", "value": 1200},
184+
{"op": "maxSupports"},
185+
{"op": "maxNgPlusSupports"},
186+
{"op": "unlockNgPlusRoster"},
187+
]}), encoding="utf-8")
188+
bulk_target = directory / "bulk-unlocked-slot00"
189+
run(cli, "apply", "--input", str(source), "--patch", str(bulk_patch),
190+
"--output", str(bulk_target))
191+
current_supports = run(cli, "inspect", "--input", str(bulk_target),
192+
"--section", "supports")["supports"]
193+
assert current_supports[0]["rank"] == "S"
194+
assert current_supports[0]["value"] == 1200
195+
assert current_supports[65]["rank"] == "B"
196+
assert current_supports[35]["value"] == 0
197+
bulk_history = run(cli, "inspect", "--input", str(bulk_target),
198+
"--section", "inheritance")["inheritance"]
199+
assert bulk_history["supports"][0]["maxPoints"] == 1001
200+
assert bulk_history["supports"][65]["maxPoints"] == 301
201+
assert bulk_history["supports"][35]["maxPoints"] == 0
202+
for record in [*range(35), *range(38, 42), 43, 44]:
203+
assert bulk_history["characters"][record]["skillRanks"] == [11] * 11
204+
for record in [35, 36, 37, 42]:
205+
assert bulk_history["characters"][record]["skillRanks"] == [0] * 11
206+
assert 42 in bulk_history["characters"][0]["masteredClassIds"]
207+
assert 54 not in bulk_history["characters"][0]["masteredClassIds"]
208+
assert 54 in bulk_history["characters"][1]["masteredClassIds"]
209+
assert 40 in bulk_history["characters"][2]["masteredClassIds"]
210+
assert 44 in bulk_history["characters"][3]["masteredClassIds"]
211+
assert 17 in bulk_history["characters"][4]["masteredClassIds"]
212+
assert 91 in bulk_history["characters"][43]["masteredClassIds"]
213+
assert 23 not in bulk_history["characters"][1]["masteredClassIds"]
214+
assert run(cli, "apply", "--input", str(bulk_target),
215+
"--patch", str(bulk_patch), "--dry-run")["changedBytes"] == 0
216+
181217
item_patch = directory / "character-item.json"
182218
item_patch.write_text(json.dumps({"operations": [
183219
{"op": "setCharacterItem", "slot": 0, "itemSlot": 0, "id": 22},

0 commit comments

Comments
 (0)