From cf3a703c1d1008c7c1612e2148c2dd3294dd6ce5 Mon Sep 17 00:00:00 2001 From: bvoo Date: Mon, 24 Aug 2026 19:29:15 -0700 Subject: [PATCH 1/2] [verified] feat: allow editing Pal experience --- README.md | 2 +- palworld_pal_edit/PalEdit.py | 32 +++++++++++++++++++++--- palworld_pal_edit/PalInfo.py | 11 +++++++-- tests/__init__.py | 0 tests/test_pal_experience.py | 47 ++++++++++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_pal_experience.py diff --git a/README.md b/README.md index d9a29cf1..3f124822 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ through the *Pal Genetic Data* terminal in-game. ``` (there's one numbered folder per account — see [Backing up your save](#-backing-up-your-save) for how to find it.) -3. **Edit away.** You'll see every Pal in your Global Palbox. Select one to change its level, stats/IVs, souls, moves, passives, nickname or species, or use **Add New Pal**, **Clone Pal** and **Delete Pal** to manage the box (see [Adding, cloning & deleting Pals](#-adding-cloning--deleting-pals)). +3. **Edit away.** You'll see every Pal in your Global Palbox. Select one to change its level, cumulative XP, stats/IVs, souls, moves, passives, nickname or species, or use **Add New Pal**, **Clone Pal** and **Delete Pal** to manage the box (see [Adding, cloning & deleting Pals](#-adding-cloning--deleting-pals)). 4. **Save.** Choose **File → Save**. The first save of each session automatically copies your original file into a `PalEdit-backups` folder next to it, just in case. 5. **Pick your changes up in-game** — see below. diff --git a/palworld_pal_edit/PalEdit.py b/palworld_pal_edit/PalEdit.py index 6939c9ef..863447d4 100644 --- a/palworld_pal_edit/PalEdit.py +++ b/palworld_pal_edit/PalEdit.py @@ -763,6 +763,7 @@ def onselect(self, evt): self.title.config(text=f"{pal.GetNickname()}") self.levelvar.set(str(pal.GetLevel()) if pal.GetLevel() > 0 else "?") + self.expvar.set(str(pal.GetExp())) self._fruit_all = [PalInfo.PalAttacks[aval] for aval in self.availableAttacks(pal)] self.fruitOptions['values'] = self._fruit_all @@ -1533,6 +1534,20 @@ def setlevelfromentry(self, *_): else: self.levelvar.set(str(lv)) # normalise (clamp / stray whitespace) + def setexpfromentry(self, *_): + """Set the selected pal's cumulative XP from the XP field.""" + if not self.isPalSelected(): + return + i = int(self.listdisplay.curselection()[0]) + pal = self.FilteredPals()[i] + try: + exp = int(self.expvar.get()) + except (ValueError, tk.TclError): + self.expvar.set(str(pal.GetExp())) + return + pal.SetExp(exp) + self.expvar.set(str(pal.GetExp())) + # ------------------------------------------------------------------ # Custom passive-skill presets (build named sets, stamp onto pals) # ------------------------------------------------------------------ @@ -2837,9 +2852,8 @@ def filterfruit(evt=None): headerframe.grid_columnconfigure((0, 2), uniform="equal") headerframe.grid_columnconfigure(1, weight=1) - # "Lv." label + a typeable entry: type a level and press Enter (or click - # away) to set it directly; the ➖ / ➕ buttons still work and the field - # updates to match + # Type a level or cumulative XP and press Enter (or click away) to set + # it directly. The ➖ / ➕ buttons still update both fields. lvlframe = tk.Frame(headerframe, bg="darkgrey") lvlframe.grid(row=0, column=1, sticky="nsew") lvlframe.grid_rowconfigure(0, weight=1) @@ -2854,8 +2868,18 @@ def filterfruit(evt=None): self.level.grid(row=0, column=2) self.level.bind("", self.setlevelfromentry) self.level.bind("", self.setlevelfromentry) + + explabel = tk.Label(lvlframe, text="XP", bg="darkgrey", font=(PalEditConfig.font, 10)) + explabel.grid(row=1, column=1, sticky="e") + self.expvar = tk.StringVar() + self.exp = tk.Entry(lvlframe, textvariable=self.expvar, width=12, justify="center", + font=(PalEditConfig.font, 10), relief="flat", bd=0, + highlightthickness=0, bg="darkgrey", disabledbackground="darkgrey") + self.exp.grid(row=1, column=2, sticky="w") + self.exp.bind("", self.setexpfromentry) + self.exp.bind("", self.setexpfromentry) # keep the owner-on-hover readout that the old level label had - for _w in (lvlframe, lvllabel, self.level): + for _w in (lvlframe, lvllabel, self.level, explabel, self.exp): _w.bind("", lambda evt, num="owner": self.changetext(num)) _w.bind("", lambda evt, num=-1: self.changetext(num)) diff --git a/palworld_pal_edit/PalInfo.py b/palworld_pal_edit/PalInfo.py index 87ef7e3f..99541140 100644 --- a/palworld_pal_edit/PalInfo.py +++ b/palworld_pal_edit/PalInfo.py @@ -303,7 +303,7 @@ def __init__(self, data): if not "Exp" in self._obj: self._obj['Exp'] = copy.deepcopy(EmptyExpObject) - # We don't store Exp yet + self._exp = self._obj['Exp']['value'] self._nickname = "" if "NickName" in self._obj: @@ -703,11 +703,18 @@ def GetOwner(self): def GetLevel(self): return self._level + def GetExp(self): + return self._exp + + def SetExp(self, value): + value = max(0, min(2_147_483_647, value)) + self._obj['Exp']['value'] = self._exp = value + def SetLevel(self, value): # We need this check until we fix adding missing nodes if "Level" in self._obj and "Exp" in self._obj: self._obj['Level']['value']["value"] = self._level = value - self._obj['Exp']['value'] = xpthresholds[value - 1] + self.SetExp(xpthresholds[value - 1]) self.CleanseAttacks() # self.SetLevelMoves() else: print(f"[ERROR:] Failed to update level for: '{self.GetName()}'") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_pal_experience.py b/tests/test_pal_experience.py new file mode 100644 index 00000000..7f47ce46 --- /dev/null +++ b/tests/test_pal_experience.py @@ -0,0 +1,47 @@ +import unittest + +from palworld_pal_edit.PalInfo import PalEntity, xpthresholds + + +class PalExperienceTests(unittest.TestCase): + def make_pal(self, level=12, experience=1600): + pal = PalEntity.__new__(PalEntity) + pal._level = level + pal._exp = experience + pal._obj = { + "Level": {"value": {"value": level}}, + "Exp": {"value": experience}, + } + pal.CleanseAttacks = lambda: None + return pal + + def test_experience_can_be_read_and_written(self): + pal = self.make_pal() + + self.assertEqual(1600, pal.GetExp()) + + pal.SetExp(1750) + + self.assertEqual(1750, pal.GetExp()) + self.assertEqual(1750, pal._obj["Exp"]["value"]) + + def test_changing_level_updates_the_experience_value(self): + pal = self.make_pal() + + pal.SetLevel(20) + + self.assertEqual(xpthresholds[19], pal.GetExp()) + self.assertEqual(xpthresholds[19], pal._obj["Exp"]["value"]) + + def test_experience_is_kept_in_the_save_integer_range(self): + pal = self.make_pal() + + pal.SetExp(-1) + self.assertEqual(0, pal.GetExp()) + + pal.SetExp(2_147_483_648) + self.assertEqual(2_147_483_647, pal.GetExp()) + + +if __name__ == "__main__": + unittest.main() From cf8d167f30dd2d60ea7d2b5e18e0746e8f9af339 Mon Sep 17 00:00:00 2001 From: bvoo Date: Mon, 24 Aug 2026 19:50:57 -0700 Subject: [PATCH 2/2] [verified] feat: add level-aware XP tools --- README.md | 2 +- palworld_pal_edit/PalEdit.py | 76 ++++++++++++++++++++++++++++++++++++ palworld_pal_edit/PalInfo.py | 43 +++++++++++++++++++- tests/test_pal_experience.py | 38 +++++++++++++++++- 4 files changed, 156 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3f124822..d0ed2457 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ through the *Pal Genetic Data* terminal in-game. ``` (there's one numbered folder per account — see [Backing up your save](#-backing-up-your-save) for how to find it.) -3. **Edit away.** You'll see every Pal in your Global Palbox. Select one to change its level, cumulative XP, stats/IVs, souls, moves, passives, nickname or species, or use **Add New Pal**, **Clone Pal** and **Delete Pal** to manage the box (see [Adding, cloning & deleting Pals](#-adding-cloning--deleting-pals)). +3. **Edit away.** You'll see every Pal in your Global Palbox. Select one to change its level, cumulative XP, stats/IVs, souls, moves, passives, nickname or species, or use **Add New Pal**, **Clone Pal** and **Delete Pal** to manage the box (see [Adding, cloning & deleting Pals](#-adding-cloning--deleting-pals)). The XP controls show progress toward the next level, flag level/XP mismatches, and can set the current level's minimum or maximum XP or derive the level from XP. 4. **Save.** Choose **File → Save**. The first save of each session automatically copies your original file into a `PalEdit-backups` folder next to it, just in case. 5. **Pick your changes up in-game** — see below. diff --git a/palworld_pal_edit/PalEdit.py b/palworld_pal_edit/PalEdit.py index 863447d4..cca57a4e 100644 --- a/palworld_pal_edit/PalEdit.py +++ b/palworld_pal_edit/PalEdit.py @@ -764,6 +764,7 @@ def onselect(self, evt): self.title.config(text=f"{pal.GetNickname()}") self.levelvar.set(str(pal.GetLevel()) if pal.GetLevel() > 0 else "?") self.expvar.set(str(pal.GetExp())) + self.updateexperiencedisplay(pal) self._fruit_all = [PalInfo.PalAttacks[aval] for aval in self.availableAttacks(pal)] self.fruitOptions['values'] = self._fruit_all @@ -1544,9 +1545,68 @@ def setexpfromentry(self, *_): exp = int(self.expvar.get()) except (ValueError, tk.TclError): self.expvar.set(str(pal.GetExp())) + self.updateexperiencedisplay(pal) return pal.SetExp(exp) self.expvar.set(str(pal.GetExp())) + self.updateexperiencedisplay(pal) + + def updateexperiencedisplay(self, pal): + progress = PalInfo.experience_progress( + pal.GetLevel(), pal.GetExp(), PalEditConfig.levelcap) + self.expprogress['value'] = progress["percent"] + + if not progress["matches_level"]: + derived = PalInfo.level_for_experience( + pal.GetExp(), PalEditConfig.levelcap) + text = f"XP matches Lv. {derived}, not Lv. {pal.GetLevel()}" + colour = "#A00000" + elif progress["next_threshold"] is None: + text = f"Maximum level · {pal.GetExp():,} XP" + colour = "#1F6B2A" + else: + text = (f"{progress['remaining']:,} XP to Lv. {pal.GetLevel() + 1} " + f"({pal.GetExp():,} / {progress['next_threshold']:,})") + colour = "#202020" + self.expstatus.config(text=text, fg=colour) + + def setexptolevelminimum(self): + if not self.isPalSelected(): + return + i = int(self.listdisplay.curselection()[0]) + pal = self.FilteredPals()[i] + progress = PalInfo.experience_progress( + pal.GetLevel(), pal.GetExp(), PalEditConfig.levelcap) + pal.SetExp(progress["minimum"]) + self.expvar.set(str(pal.GetExp())) + self.updateexperiencedisplay(pal) + + def setexptolevelmaximum(self): + if not self.isPalSelected(): + return + i = int(self.listdisplay.curselection()[0]) + pal = self.FilteredPals()[i] + progress = PalInfo.experience_progress( + pal.GetLevel(), pal.GetExp(), PalEditConfig.levelcap) + pal.SetExp(progress["maximum"]) + self.expvar.set(str(pal.GetExp())) + self.updateexperiencedisplay(pal) + + def derivelevelfromexp(self): + if not self.isPalSelected(): + return + i = int(self.listdisplay.curselection()[0]) + pal = self.FilteredPals()[i] + experience = pal.GetExp() + level = PalInfo.level_for_experience( + experience, PalEditConfig.levelcap) + if level != pal.GetLevel(): + pal.SetLevel(level) + pal.SetExp(experience) + self.handleMaxHealthUpdates(pal) + self.refresh(i) + else: + self.updateexperiencedisplay(pal) # ------------------------------------------------------------------ # Custom passive-skill presets (build named sets, stamp onto pals) @@ -2893,6 +2953,22 @@ def filterfruit(evt=None): bg="darkgrey") addlvlbtn.grid(row=0, column=2, sticky="nsew") + xpdetailframe = tk.Frame(deckview, bg="darkgrey", padx=5, pady=3) + xpdetailframe.pack(fill=tk.constants.X) + self.expprogress = ttk.Progressbar(xpdetailframe, maximum=100) + self.expprogress.pack(fill=tk.constants.X) + self.expstatus = tk.Label(xpdetailframe, text="", bg="darkgrey", + font=(PalEditConfig.font, 9)) + self.expstatus.pack(fill=tk.constants.X) + xpbuttons = tk.Frame(xpdetailframe, bg="darkgrey") + xpbuttons.pack(fill=tk.constants.X) + tk.Button(xpbuttons, text="Level minimum", command=self.setexptolevelminimum, + font=(PalEditConfig.font, 8)).pack(side=tk.constants.LEFT, expand=True, fill=tk.constants.X) + tk.Button(xpbuttons, text="Level maximum", command=self.setexptolevelmaximum, + font=(PalEditConfig.font, 8)).pack(side=tk.constants.LEFT, expand=True, fill=tk.constants.X) + tk.Button(xpbuttons, text="Level from XP", command=self.derivelevelfromexp, + font=(PalEditConfig.font, 8)).pack(side=tk.constants.LEFT, expand=True, fill=tk.constants.X) + baseinfoview = tk.Frame(deckview) baseinfoview.pack(fill=tk.constants.BOTH) labelview = tk.Frame(baseinfoview, bg="lightgrey", pady=0, padx=16) diff --git a/palworld_pal_edit/PalInfo.py b/palworld_pal_edit/PalInfo.py index 99541140..aa01d171 100644 --- a/palworld_pal_edit/PalInfo.py +++ b/palworld_pal_edit/PalInfo.py @@ -125,6 +125,47 @@ while len(xpthresholds) < 80: xpthresholds.append(xpthresholds[-1]) +MAX_EXPERIENCE = 2_147_483_647 + + +def level_for_experience(experience, level_cap=80): + """Return the highest level threshold reached by cumulative XP.""" + experience = max(0, min(MAX_EXPERIENCE, experience)) + level_cap = max(1, min(level_cap, len(xpthresholds))) + for level in range(level_cap, 0, -1): + if experience >= xpthresholds[level - 1]: + return level + return 1 + + +def experience_progress(level, experience, level_cap=80): + """Describe cumulative XP progress and consistency for a selected level.""" + level_cap = max(1, min(level_cap, len(xpthresholds))) + level = max(1, min(level, level_cap)) + experience = max(0, min(MAX_EXPERIENCE, experience)) + minimum = xpthresholds[level - 1] + next_threshold = xpthresholds[level] if level < level_cap else None + maximum = next_threshold - 1 if next_threshold is not None else MAX_EXPERIENCE + matches_level = minimum <= experience <= maximum + + if next_threshold is None: + percent = 100 if experience >= minimum else 0 + remaining = None + else: + required = next_threshold - minimum + earned = max(0, min(required, experience - minimum)) + percent = earned * 100 // required + remaining = max(0, next_threshold - experience) + + return { + "minimum": minimum, + "maximum": maximum, + "next_threshold": next_threshold, + "remaining": remaining, + "percent": percent, + "matches_level": matches_level, + } + class PalGender(Enum): @@ -707,7 +748,7 @@ def GetExp(self): return self._exp def SetExp(self, value): - value = max(0, min(2_147_483_647, value)) + value = max(0, min(MAX_EXPERIENCE, value)) self._obj['Exp']['value'] = self._exp = value def SetLevel(self, value): diff --git a/tests/test_pal_experience.py b/tests/test_pal_experience.py index 7f47ce46..a4db5fc0 100644 --- a/tests/test_pal_experience.py +++ b/tests/test_pal_experience.py @@ -1,6 +1,11 @@ import unittest -from palworld_pal_edit.PalInfo import PalEntity, xpthresholds +from palworld_pal_edit.PalInfo import ( + PalEntity, + experience_progress, + level_for_experience, + xpthresholds, +) class PalExperienceTests(unittest.TestCase): @@ -42,6 +47,37 @@ def test_experience_is_kept_in_the_save_integer_range(self): pal.SetExp(2_147_483_648) self.assertEqual(2_147_483_647, pal.GetExp()) + def test_experience_progress_reports_the_current_level_range(self): + progress = experience_progress(level=1, experience=10, level_cap=80) + + self.assertEqual(0, progress["minimum"]) + self.assertEqual(24, progress["maximum"]) + self.assertEqual(25, progress["next_threshold"]) + self.assertEqual(15, progress["remaining"]) + self.assertEqual(40, progress["percent"]) + self.assertTrue(progress["matches_level"]) + + def test_experience_progress_flags_values_outside_the_level_range(self): + below = experience_progress(level=12, experience=xpthresholds[11] - 1, level_cap=80) + above = experience_progress(level=12, experience=xpthresholds[12], level_cap=80) + + self.assertFalse(below["matches_level"]) + self.assertFalse(above["matches_level"]) + + def test_max_level_progress_has_no_next_level(self): + progress = experience_progress(level=80, experience=2_147_483_647, level_cap=80) + + self.assertEqual(2_147_483_647, progress["maximum"]) + self.assertIsNone(progress["next_threshold"]) + self.assertIsNone(progress["remaining"]) + self.assertEqual(100, progress["percent"]) + self.assertTrue(progress["matches_level"]) + + def test_level_can_be_derived_from_experience(self): + self.assertEqual(1, level_for_experience(24, level_cap=80)) + self.assertEqual(2, level_for_experience(25, level_cap=80)) + self.assertEqual(80, level_for_experience(2_147_483_647, level_cap=80)) + if __name__ == "__main__": unittest.main()