| name | pst-pal-editor |
| description | The pal editing engine — pal data model (every SaveParameter field with type-wrapper shapes), editing operations, illegal pal detection, pal generation, container/slot model, game data binding, and the PalEditorWidget UI. Previously the monolithic `edit_pals.py` (5454 lines), now a modular `pal_editor/` package (17 modules + backward-compat `edit_pals.py` shim). Load when editing pal logic, stats, skills, IVs, containers, or illegal detection. |
PST Pal Editor Engine (pal_editor/ subpackage)
Originally a 5454-line monolith (edit_pals.py), now a modular package at src/palworld_aio/editor/pal_editor/ with 17 modules + backward-compat shim (edit_pals.py re-exports all public symbols).
Module structure (17 modules)
| Module | Key contents |
|---|
__init__.py | Re-exports all public symbols |
data.py | Data loaders, caches, constants (_ensure_passive_data, _ensure_skill_data) |
icons.py | Icons, pixmaps, badges, _strip_prefix_label |
pal_ops.py | Raw-dict mutation functions, build_pal_context_menu |
widgets.py | Self-contained widget classes (StrokedLabel, _ShinyStar, etc.) |
card_widgets.py | Pal card grid rendering |
party_slot_widget.py | Party slot (5 slots) |
palbox_slot_widget.py | Palbox slot (30 per box) |
pal_info_widget.py | PalInfoWidget — main detailed editor panel |
pal_info_display.py | PalInfoWidget display mixin (render updates, element colors) |
pal_info_handlers.py | PalInfoWidget handler mixin (click handlers for all editable fields) |
create_dialogs.py | BulkSyncPalDialog, PalCreateDialog, _show_learned_moves_dialog |
pal_editor_widget.py | PalEditorWidget — main composition widget |
pal_editor_bulk_ops.py | BulkOperationMixin (batch rename/feed/heal) |
pal_editor_global_ops.py | Global ops (delete/remove-skill from all pals) |
legacy_frame.py | PalFrame — legacy frame with global game-data maps |
edit_pals.py (shim) | Backward-compat re-exports |
Classes (23)
Key ones:
- PalEditorWidget (pal_editor_widget.py, QWidget) — main composition: partyPanel (5×PartySlotWidget) + palboxPanel (5×6 grid of 30 PalboxSlotWidget, 32 boxes) + PalInfoWidget. Hotkeys Q/E=box nav, F=focus info.
- PalInfoWidget (pal_info_widget.py, QFrame) — THE detailed editor panel. Signal pal_data_changed. All click-edit handlers. Mixin: pal_info_display.py + pal_info_handlers.py.
- PalIcon (widgets.py) / PalCardWidget (card_widgets.py) — grid cells with badges (boss/lucky/awake/dna/fav).
- PartySlotWidget (party_slot_widget.py) / PalboxSlotWidget (palbox_slot_widget.py) — slot widgets. Signals: clicked, rightClicked(slot,action), entered, left. Double-click→delete/add.
- PalFrame (legacy_frame.py) — legacy display frame; holds global game-data maps (_NAMEMAP, _PASSMAP, _SKILLMAP, _PASSRANK, _PASSFLAGS) via _load_maps.
- PalCreateDialog (create_dialogs.py) — searchable species picker → _generate_pal_save_param → append to save+container+guild.
- BulkSyncPalDialog (create_dialogs.py) — copy _EDITABLE_KEYS from one pal to same-species pals.
Known extraction-regression bugs (⚠️ pitfall warning)
During the Phase 1 partition (monolith → subpackage), 7 out of ~10 extracted methods were broken during extraction into pal_info_handlers.py. These are not edge cases — they are systemic extraction errors to guard against:
_on_soul_click: Missing dlg.exec() + write-back + _recalc_hp() calls. The dialog was constructed but never shown, and user input was never applied.
_show_skill_picker: Rewritten as a stub — passed wrong args, used wrong method name (exec() vs pick()), referenced non-existent selected_asset.
_tick_star_shine: Rewritten to call .flash() on one label (method doesn't exist). Original advances phase counter incrementally and calls set_phase() on ALL shine labels.
_set_active_skill: Wrong array_type (NameProperty → needs EnumProperty), wrong item format (dict vs plain string), missing empty-item filtering, missing EPalWazaID:: prefix on values.
_get_current_passive_list: Missing dict-unwrapping (v['value'] when items are dicts).
- Caller of skill-picker: Passed
_data._SKILL_DATA (dict-of-dicts) instead of PalFrame._SKILLMAP (flat asset→name dict).
_on_portrait_context_menu (pal_info_widget.py): build_pal_context_menu was rewritten to return a single ScrollableContextMenu (string-key dispatch), but this caller was left on the old menu, actions = ... tuple-unpacking pattern inherited from the monolith.
Guard against extraction regressions by:
- Comparing EVERY line of extracted methods to the original (never rewrite from scratch).
- Auditing ALL callers when a shared utility's return signature changes.
- Running
diff <(git show HEAD:original_file) extracted_file on extracted sections.
- Loading the QA audit protocol from project memory (§8 Errors and fixes) for any future extraction work.
Pal data model (THE save structure)
Access path: entry['value']['RawData']['value']['object']['SaveParameter']['value'] → flat dict of {field: typed_wrapper}.
Type-wrapper shapes:
- ByteProperty:
{id,type:'ByteProperty',value:{type:'None',value:<int>}}
- IntProperty/Int64Property:
{id,type,value:<int>}
- FloatProperty:
{id,type,value:<float>}
- BoolProperty:
{id,type,value:<bool>}
- NameProperty/StrProperty:
{id,type,value:<str>}
- EnumProperty:
{id,type,value:{type:<EnumType>,value:<EnumStr>}}
- StructProperty(FixedPoint64): HP stored ×1000:
{struct_type:'FixedPoint64',value:{Value:{value:<int>}}}
- StructProperty(Guid):
{struct_type:'Guid',value:<uuid-str>}
- ArrayProperty(Name/Enum):
{array_type,value:{values:[<str>...]}}
Editable fields + legal ranges:
| Field | Wrapper | Range | Notes |
|---|
| CharacterID | NameProperty | valid asset | BOSS_ prefix = boss |
| Level | Byte/Int | 1-80 | illegal if >80 |
| Exp | Int64 | from pal_exp_table | _set_level sets both |
| Gender | Enum EPalGenderType | ::Male/::Female | toggle |
| NickName | StrProperty | str | |
| Hp/MaxHP | Struct FixedPoint64 (×1000) | | recalced via calculate_max_hp |
| Talent_HP/Talent_Shot/Talent_Defense | Byte | 0-100 (IVs) | illegal if >100 |
| Rank_HP/Rank_Attack/Rank_Defence/Rank_CraftSpeed | Byte | 0-20 (souls) | illegal if >20 |
| Rank | Byte | 1-5 (condenser stars) | illegal if >5 |
| FriendshipPoint | Int | 0-200000 | trust; thresholds from friendship.json |
| PassiveSkillList | Array(Name) | ≤4 | illegal if >4; _set_passive_skill caps [:4] |
| EquipWaza | Array(Enum EPalWazaID::) | ≤3 non-empty | illegal if >3; _set_active_skill caps [:3] |
| MasteredWaza | Array(Enum) | unbounded | learnable; _learn_all_skills fills |
| IsRarePal | Bool | | "lucky"/shiny |
| bIsAwakening | Bool | | awakened (×1.028 HP) |
| bImportedCharacter | Bool | | "DNA/imported" flag |
| FavoriteIndex | Byte | 0-3 | lock level |
| FullStomach | Float | ~0-300 | hunger |
| SanityValue | Float | 0-100 | SAN |
| SlotId | Struct PalCharacterSlotId | | {ContainerId:{ID:{Guid}}, SlotIndex:{Int}} |
| OwnerPlayerUId | Struct(Guid) | | owner |
Stat formulas (utils.py):
- calculate_max_hp(205):
floor(500 + 5·lvl + hp_scaling·0.5·lvl·(1+IV·0.3/100)·α)·(1+condenser_bonus)·(1+soul·0.03)·(1+trust·0.03)·awaken(1.028) ×1000; α=1.087 if boss/lucky; condenser_bonus=(Rank-1)·0.05.
- calculate_shot_attack(218) / calculate_defense(239) / calculate_attack(229): similar scaling.
Pal generation
_generate_pal_save_param(character_id, nickname, owner_uid, container_id, slot_index, group_id=None) (1808): builds full cmap entry. Defaults: Gender=Female, Talent_*=100 (maxed IVs), FullStomach=150, Hp=calculate_max_hp(lvl1,100iv). EquipWaza=[Unique_Roll] only for SheepBall. OwnedTime=638486453957560000. NO Level/Exp/Rank/souls given (game defaults).
_register_pal_instance_to_guild(instance_id, group_id) (1731): appends {guid,instance_id} to GroupSaveDataMap matching guild's individual_character_handle_ids.
Create wiring (PalCreateDialog._on_create 5235): resolve group_id from player's guild → compute slot ((box-1)*30+slot) → append cmap entry → append CharacterContainerSaveData slot → register to guild → increment PLAYER_PAL_COUNTS.
Editing operations
All mutate self._raw in place then self._refresh(). (Line numbers reference the original edit_pals.py monolith; use git log -p -S 'method_name' to find current locations.)
| Op | Method(line) | Edits |
|---|
| Level | _set_level(3543) | Level,Exp,Hp,MaxHP |
| Stars/Rank | _on_star_click(3457) | Rank (cycles 1-5) |
| IVs | _on_talent_click(3575) | Talent_HP/Shot/Defense (0-100) |
| Souls | _on_soul_click(3591) | Rank_HP/Attack/Defence/CraftSpeed (0-20) |
| Gender | _on_gender_click(3446) | Gender enum |
| Nickname | _on_name_click(3431) | NickName |
| Boss toggle | _toggle_boss_raw(1613) | CharacterID (BOSS_ prefix), IsRarePal |
| Lucky toggle | _toggle_lucky_raw(1623) | IsRarePal, CharacterID |
| Awakened | _toggle_awake_raw(1631) | bIsAwakening |
| DNA/imported | _toggle_dna_raw(1633) | bImportedCharacter |
| Favorite/lock | _set_fav_raw(1635) | FavoriteIndex (0-3) |
| Trust | _on_trust_click(3520) | FriendshipPoint |
| Active skill | _set_active_skill(3666) | EquipWaza (caps [:3]) |
| Passive skill | _set_passive_skill(3679) | PassiveSkillList (caps [:4]) |
| Learn all | _learn_all_skills_raw(1560) | MasteredWaza(full)+EquipWaza(≤3 by power) |
| Work suitability | _set_work_suitability(1678) | GotWorkSuitabilityAddRankList |
| Max all | _max_stats_raw(1713) | IVs=100,souls=20,Rank=5,trust=200k,awake,WS=10,lvl=80 |
| Bulk sync | BulkSyncPalDialog._on_apply(4327) | copies _EDITABLE_KEYS to same-species |
NO in-place species swap — species change only via BOSS_ toggle or delete+create.
NO move-between-containers — transfer = delete+create. All DnD explicitly disabled (setAcceptDrops(False)).
EDITABLE_KEYS(4195): Level,Exp,Gender,Talent,Rank_,Rank,FriendshipPoint,IsRarePal,bIsAwakening,bImportedCharacter,FavoriteIndex,EquipWaza,MasteredWaza,PassiveSkillList,Hp,MaxHP,GotWorkSuitabilityAddRankList.
Illegal pal detection (func_manager.py)
check_is_illegal_pal(1637) → (is_illegal, markers). Thresholds: Level>80, any IV>100, any Soul>20, Passives>4, ActiveSkills>3(non-empty), Rank>5.
fix_illegal_pals_in_save(1857): clamps to ceilings (Level→80+Exp, IVs→100, souls→20, Rank→5, passives→[:4], active→[:3]). Processes CharacterSaveParameterMap + per-player *_dps.sav via ProcessPoolExecutor.
remove_invalid_pals_from_save(881): drops cmap entries whose CharacterID ∉ characters.json pals∪npcs.
remove_invalid_passives_from_save(1248): removes PassiveSkillList entries ∉ skills.json passives.
Container/slot model
- Party container = player .sav SaveData.OtomoCharacterContainerId.ID; palbox = PalStorageContainerId.ID. DPS file = _dps.sav.
- CharacterContainerSaveData: {key:{ID:{Guid}}, value:{Slots:{values:[{SlotIndex,RawData:{instance_id}}]}}}.
- Palbox: 32 boxes × 30 slots (5×6). abs_index = (box-1)*30 + slot. Party: 5 slots.
- ContainerOwnership.build(cmap,container_data): majority-vote maps instance→container→owner. get_effective_owner(66) resolves base-worker pals.
- PLAYER_PAL_COUNTS[uid] tracks counts; incremented on create, decremented on delete.
Game data binding
PalFrame._load_maps(5300): _NAMEMAP(pals+npcs), _PASSMAP(passives), _SKILLMAP(skills), _PAL_ZUKAN(zukan_index), _PASSRANK/PASSFLAGS. All via data_manager.load_game_data_map(file,key) → {asset.lower():name}. Passives filtered: category=='SortDisplayable' AND no add_weapon/armor/accessory.
get_pal_base_data(cid)(93): lazy characters.json cache, backfills from boss/gym variants.
Passive rank colors: _RANK_COLORS(839) maps -99/1/2/4/5; ≥5=world_tree overlay, ≥4=legend.