OpenParable
Skip to content
← Back to skills

pst-pal-editor

Development

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.

View on GitHub ↗
QUICK START

How to use this skill

Bring this guide into your coding agent with a prompt tailored to the tool you use.

  1. Open your project in Codex.
  2. Copy and paste the prompt below.
  3. Review files and risks before approving any action.
Prompt to paste
Inspect the Agent Skill at https://github.com/deafdudecomputers/PalworldSaveTools/blob/HEAD/.opencode/skills/pst-pal-editor/SKILL.md and all supporting files. Report provenance, permissions, scripts, dependencies, and security risks. Propose an exact installation into .agents/skills/pst-pal-editor, but wait for my approval before writing files or running scripts.

Copying this prompt does not install or run the skill. Review third-party files before use.

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)

ModuleKey contents
__init__.pyRe-exports all public symbols
data.pyData loaders, caches, constants (_ensure_passive_data, _ensure_skill_data)
icons.pyIcons, pixmaps, badges, _strip_prefix_label
pal_ops.pyRaw-dict mutation functions, build_pal_context_menu
widgets.pySelf-contained widget classes (StrokedLabel, _ShinyStar, etc.)
card_widgets.pyPal card grid rendering
party_slot_widget.pyParty slot (5 slots)
palbox_slot_widget.pyPalbox slot (30 per box)
pal_info_widget.pyPalInfoWidget — main detailed editor panel
pal_info_display.pyPalInfoWidget display mixin (render updates, element colors)
pal_info_handlers.pyPalInfoWidget handler mixin (click handlers for all editable fields)
create_dialogs.pyBulkSyncPalDialog, PalCreateDialog, _show_learned_moves_dialog
pal_editor_widget.pyPalEditorWidget — main composition widget
pal_editor_bulk_ops.pyBulkOperationMixin (batch rename/feed/heal)
pal_editor_global_ops.pyGlobal ops (delete/remove-skill from all pals)
legacy_frame.pyPalFrame — 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:

  1. _on_soul_click: Missing dlg.exec() + write-back + _recalc_hp() calls. The dialog was constructed but never shown, and user input was never applied.
  2. _show_skill_picker: Rewritten as a stub — passed wrong args, used wrong method name (exec() vs pick()), referenced non-existent selected_asset.
  3. _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.
  4. _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.
  5. _get_current_passive_list: Missing dict-unwrapping (v['value'] when items are dicts).
  6. Caller of skill-picker: Passed _data._SKILL_DATA (dict-of-dicts) instead of PalFrame._SKILLMAP (flat asset→name dict).
  7. _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:

FieldWrapperRangeNotes
CharacterIDNamePropertyvalid assetBOSS_ prefix = boss
LevelByte/Int1-80illegal if >80
ExpInt64from pal_exp_table_set_level sets both
GenderEnum EPalGenderType::Male/::Femaletoggle
NickNameStrPropertystr
Hp/MaxHPStruct FixedPoint64 (×1000)recalced via calculate_max_hp
Talent_HP/Talent_Shot/Talent_DefenseByte0-100 (IVs)illegal if >100
Rank_HP/Rank_Attack/Rank_Defence/Rank_CraftSpeedByte0-20 (souls)illegal if >20
RankByte1-5 (condenser stars)illegal if >5
FriendshipPointInt0-200000trust; thresholds from friendship.json
PassiveSkillListArray(Name)≤4illegal if >4; _set_passive_skill caps [:4]
EquipWazaArray(Enum EPalWazaID::)≤3 non-emptyillegal if >3; _set_active_skill caps [:3]
MasteredWazaArray(Enum)unboundedlearnable; _learn_all_skills fills
IsRarePalBool"lucky"/shiny
bIsAwakeningBoolawakened (×1.028 HP)
bImportedCharacterBool"DNA/imported" flag
FavoriteIndexByte0-3lock level
FullStomachFloat~0-300hunger
SanityValueFloat0-100SAN
SlotIdStruct PalCharacterSlotId{ContainerId:{ID:{Guid}}, SlotIndex:{Int}}
OwnerPlayerUIdStruct(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.)

OpMethod(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 syncBulkSyncPalDialog._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.