Documentation
¶
Overview ¶
Package palworld reads Palworld saves by supplying the game knowledge the generic layers below it deliberately lack.
savefile knows the container and gvas knows Unreal's property encoding, but neither can tell which concrete struct hides behind an untagged StructProperty — Unreal's legacy tags omit that for map keys, map values, and set elements. This package owns the path-to-struct table that resolves them, which makes it the one place where a Palworld version change is expected to land.
Load is the entry point for the rest of the program. Callers that want no game knowledge at all can still reach savefile and gvas directly.
Index ¶
- func Load(path string) (*savefile.Save, error)
- func LoadWithOptions(path string, options savefile.Options) (*savefile.Save, error)
- func RawDataPaths() map[string]RawDataKind
- func TypeHints() map[string]string
- type BaseCamp
- type Character
- type CharacterHandle
- type CharacterSlot
- type Group
- type Guild
- type GuildMember
- type ItemSlot
- type RawDataKind
- type Transform
- type WorkerDirector
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Load ¶
Load reads, decompresses, and parses a Palworld .sav file with the game's type hints applied.
func LoadWithOptions ¶
LoadWithOptions is Load with explicit limits and additional type hints. A hint the caller supplies overrides the built-in hint at the same path, which is how a new game build can be worked around without changing this package.
func RawDataPaths ¶
func RawDataPaths() map[string]RawDataKind
RawDataPaths returns the property paths with a known RawData layout. The result is a copy.
Types ¶
type BaseCamp ¶
type BaseCamp struct {
// ID is the camp's own id and equals the map key it was read from.
ID gvas.GUID
// Name is the camp's name. In the fixture world all 19 are the game's
// Japanese default, which is also what proves the string reader handles
// UTF-16 against real data rather than only a test fixture.
Name string
// State is a single byte, 1 in every fixture camp.
State uint8
// Transform is where the camp is. Its Translation is the camp's position in
// world units, on the same scale as a player's LastTransform.
Transform Transform
// AreaRange is the camp's radius in world units, 3500 in all 19 fixture
// camps.
AreaRange float32
// GroupID keys worldSaveData.GroupSaveDataMap: the guild that owns the camp.
// All 19 fixture camps name one of the world's 8 guilds.
GroupID gvas.GUID
// FastTravel is a second transform, in the camp's local space. It is kept
// because skipping 80 bytes would make the record's shape a guess, and
// because a wrong width here would break OwnerMapObjectID -- which resolves,
// so the width is right.
FastTravel Transform
// OwnerMapObjectID is the map object this camp is built around, which matches
// an entry of the owning guild's BasePoints for all 19 fixture camps. A camp
// is created by placing a palbox, so that is very likely what the object is --
// but worldSaveData.MapObjectSaveData's own RawData is not decoded, so nothing
// here can say so.
OwnerMapObjectID gvas.GUID
// Trailer is the remainder, aliasing the caller's slice. It is four zero
// bytes in all 19 fixture camps.
Trailer []byte
}
BaseCamp is one entry of worldSaveData.BaseCampSaveData: a guild's base, where it is in the world, and which map object is its palbox.
The record is a bespoke layout with no property framing:
16 bytes Id FString Name -- UTF-16 in all 19 fixture camps 1 byte State 80 bytes Transform -- ten float64s, rotation first 4 bytes AreaRange -- float32, 3500 in all 19 16 bytes GroupID 80 bytes fast-travel transform 16 bytes OwnerMapObjectID trailer -- four zero bytes in all 19
Verified against all 19 camps of a 1.0.1.100619 world save. Two independent checks say the offsets are right rather than merely plausible: every rotation is a unit quaternion, and every GroupID names a guild that lists this camp in its own BaseIDs. Sixteen bytes read at the wrong offset can look like a GUID, but they cannot look like a GUID that names you back.
func DecodeBaseCamp ¶
DecodeBaseCamp decodes one BaseCampSaveData value's RawData. The result aliases data through Trailer, so data must not be modified afterwards.
type Character ¶
type Character struct {
// Properties is the stream as parsed, not reshaped. In all 2,259 fixture
// blobs it is exactly one "SaveParameter" StructProperty of type
// PalIndividualCharacterSaveParameter; Parameters unwraps that.
Properties gvas.Properties
// GroupID keys worldSaveData.GroupSaveDataMap -- the guild this character
// belongs to, whether it is a player or one of their pals. All eight
// distinct values in the fixture world resolve to a group entry, which is
// what distinguishes a real field from sixteen bytes that merely look like
// a GUID.
GroupID gvas.GUID
// Trailer is the framing after the stream's terminator, aliasing the
// caller's slice. GroupID is read out of it at characterGroupIDOffset; the
// eight bytes surrounding that are zero in every fixture blob and are kept
// because nothing here knows what they are. See PaddingBeyondGroupID.
Trailer []byte
}
Character is one entry of worldSaveData.CharacterSaveParameterMap: a player or a pal, decoded from the nested property stream in its RawData.
Unlike an item slot, this blob is not a bespoke record. It is the same property encoding gvas already reads, written without a GVAS header in front of it, so decoding is a re-entrant call into gvas.ParseProperties rather than a new parser. That is what makes species, level, nickname, HP and passive skills readable: gvas does the work, and this file supplies only the framing and the paths.
Verified against all 2,259 character blobs in a 1.0.1.100619 world save: every one decodes, every one holds exactly one "SaveParameter" property, and no property anywhere inside falls back to gvas.UndecodedValue -- the type-hint table already covers the nested structs.
func DecodeCharacter ¶
DecodeCharacter decodes one CharacterSaveParameterMap value's RawData. The result aliases data, which must not be modified afterwards.
func DecodeCharacterWithOptions ¶
DecodeCharacterWithOptions is DecodeCharacter with explicit limits and additional type hints, merged over the game's own the same way LoadWithOptions does it. The limits matter here: the blob is untrusted input and a nested stream is parsed with the same bounds as a whole archive.
func (Character) PaddingBeyondGroupID ¶
PaddingBeyondGroupID reports whether any trailer byte outside GroupID is non-zero. Every fixture blob pads the group id with eight zero bytes, so a caller rendering the trailer can leave it out when this is false and know it is dropping nothing.
func (Character) Parameters ¶
func (character Character) Parameters() gvas.Properties
Parameters returns the PalIndividualCharacterSaveParameter properties: the character's species, level, nickname, stats and so on.
It returns nil when the stream does not have the single-SaveParameter shape every fixture blob has, so a caller must check rather than assume. That is a deliberate split from DecodeCharacter, which reports a malformed *stream* as an error: a well-formed stream holding something unexpected is a schema change to report upward, not a decoding failure.
type CharacterHandle ¶
type CharacterHandle struct {
// PlayerUID is the account this handle belongs to, and is the zero GUID for
// a pal.
PlayerUID gvas.GUID
// InstanceID keys worldSaveData.CharacterSaveParameterMap.
InstanceID gvas.GUID
}
CharacterHandle is a reference to a character record: Unreal's FPalInstanceID, the same 32 bytes a CharacterSlot opens with.
A group lists every character that belongs to it, players and pals alike. In the fixture world the eight guilds' handles are exactly the 3,349 records of worldSaveData.CharacterSaveParameterMap, each named once -- the whole population, partitioned. The 9 handles with a non-zero PlayerUID are exactly the 9 players, so counting them counts a guild's members a second way.
An organization's handles are not so tidy: one fixture organization lists 62 characters the save does not hold. A handle is a reference, and a reference can outlive what it names.
type CharacterSlot ¶
type CharacterSlot struct {
// PlayerUID is the account half of Unreal's FPalInstanceID. It is zero in
// every fixture slot -- a character container holds pals, and a pal's
// character-map key carries the zero UID too -- so it is read rather than
// skipped precisely because a non-zero value would mean this assumption is
// wrong.
PlayerUID gvas.GUID
// InstanceID keys worldSaveData.CharacterSaveParameterMap: the pal that
// occupies this slot. It is the zero GUID for an empty slot.
InstanceID gvas.GUID
// Trailer is the remainder after InstanceID, uninterpreted and aliasing the
// caller's slice. It is six zero bytes in every fixture slot, kept rather
// than assumed to be padding.
Trailer []byte
}
CharacterSlot is one slot of a character container: a pal party, a pal storage box, or a base camp's worker list. Where an ItemSlot holds an item, this holds only a reference -- the pal itself is a CharacterSaveParameterMap entry, which is what makes this record the join between "which pals does this container hold" and "what is that pal".
The record is Unreal's FPalInstanceID written flat, with no property framing:
16 bytes PlayerUId -- zero in every slot of the fixture world 16 bytes InstanceId -- keys worldSaveData.CharacterSaveParameterMap trailer -- six zero bytes in every slot of the fixture world
Verified against all 2,250 slots in the 35 character containers of a 1.0.1.100619 world save: every slot is 38 bytes, every PlayerUId is zero, every trailer is zero, and the 2,250 InstanceId values are distinct and each matches a character record. That is also the population of pals in the world, counted a second way: a pal sits in exactly one slot.
func DecodeCharacterSlot ¶
func DecodeCharacterSlot(data []byte) (CharacterSlot, error)
DecodeCharacterSlot decodes one CharacterContainerSaveData Slots[].RawData payload. The result aliases data through Trailer, so data must not be modified afterwards.
A blob too short to hold both GUIDs is an error rather than a partial result, for the same reason DecodeItemSlot rejects a truncated record: a plausible half-read reference would resolve to nothing and look like missing data rather than a wrong layout.
func (CharacterSlot) Empty ¶
func (slot CharacterSlot) Empty() bool
Empty reports whether the slot references no character. A container declares more slots than it holds -- a pal storage box declares 960 -- but only the occupied ones are serialized, so no fixture slot is empty.
type Group ¶
type Group struct {
// ID is the group's own id, and equals the map key in all 15 fixture
// entries. It is read rather than taken from the key so a caller can check
// the two agree.
ID gvas.GUID
// Name is Palworld's internal name for the group. It is empty for all fixture
// organizations. Most fixture guilds store the admin's account id as 32 hex
// digits, while a current fixture also carries an empty value. It is not the
// guild's display name, which is in the guild half of the record.
Name string
// Handles lists every character in the group.
Handles []CharacterHandle
// OrganizationType is 0 for all eight fixture guilds and 2 to 8, one value
// each, for the seven organizations. The values are not otherwise known: an
// organization is a fixed faction, and seven distinct types across seven
// factions is what makes this the type field rather than the zero uint32
// before it.
OrganizationType uint8
// BaseIDs keys worldSaveData.BaseCampSaveData: the group's base camps. All
// 19 references in the fixture world resolve, they are distinct, and they
// account for every base camp in the save -- and each of those camps names
// this group back. See BaseCamp.GroupID.
BaseIDs []gvas.GUID
// Remainder is what follows the shared part, aliasing the caller's slice.
// For an organization it is four zero bytes in all seven fixture records;
// for a guild it is the guild record, which DecodeGuild reads.
Remainder []byte
// Unknown is the uint32 between the handles and OrganizationType. It is zero
// in all 15 fixture records, so nothing can be said about it beyond that it
// is there -- and it is read rather than skipped because a non-zero value
// would mean this reading of the record is wrong.
Unknown uint32
}
Group is the part of a GroupSaveDataMap record that every group carries.
func DecodeGroup ¶
DecodeGroup decodes the shared part of one GroupSaveDataMap value's RawData.
The guild half is a separate call because the record does not say whether it is there: the sibling GroupType property does. Deciding it here from the bytes left over would be a guess dressed up as a decoder.
The result aliases data through Remainder, so data must not be modified afterwards.
type Guild ¶
type Guild struct {
// BaseCampLevel is the guild's base camp level, 3 to 22 across the fixture
// world's eight guilds.
BaseCampLevel int32
// BasePoints has one entry per BaseIDs entry, and each matches the
// OwnerMapObjectID of a base camp -- all 19 of them in the fixture world. It
// is the map object a camp is built around, which in Palworld means the
// palbox, though nothing here confirms that: MapObjectSaveData's own RawData
// is not decoded.
BasePoints []gvas.GUID
// Name is the guild's display name, "Unnamed Guild" while it has not been
// set.
Name string
// Admin is the guild's admin and the member carrying Role 1 in the fixture
// guilds. Palworld usually also writes this account id as Group.Name, though
// current saves can leave that internal name empty.
Admin gvas.GUID
// Members lists the guild's players.
Members []GuildMember
// NamedBy is an account id written just before Admin. It is zero in exactly
// the three fixture guilds still called "Unnamed Guild" and equal to Admin
// in the other five, which is what the name records -- an exact correlation
// across eight guilds, and still an inference rather than a decoded meaning.
// Nothing in this program depends on it.
NamedBy gvas.GUID
// Reserved is the 14 bytes between NamedBy and Admin, byte-identical in all
// eight fixture guilds (a zero uint32, the uint32 2, the bytes 2 and 3, and
// a zero uint32). Kept because dropping bytes nobody has explained would
// make a change in them invisible.
Reserved []byte
// Trailer is what follows the members, aliasing the caller's slice. It is
// the same 30 bytes in all eight fixture guilds: a count of three, then
// three byte-keyed lists (2 -> 0,3,4,5,7; 3 -> 4,7; 4 -> nothing), then four
// zero bytes. That shape is legible but its meaning is not, so it is
// preserved rather than reported as fields.
Trailer []byte
}
Guild is the second half of a guild's group record: what the guild is called, who runs it, and who is in it.
This is the phase the group blob existed to unlock. A guild's display name is nowhere else in the save, and neither is the list of accounts that belong to it -- a character record names its group, but only the group names its people.
func DecodeGuild ¶
DecodeGuild decodes the guild half of a group record: pass Group.Remainder, and only when the sibling GroupType property says the group is a guild.
An organization's four-byte remainder cannot satisfy the fixed fields, so it is rejected rather than silently producing an empty guild -- which is what makes calling this on the wrong group an error instead of a plausible lie.
The result aliases data through Reserved and Trailer.
type GuildMember ¶
type GuildMember struct {
// PlayerUID is the member's account id, which names their player.sav.
PlayerUID gvas.GUID
// Name is the member's character name. It is the same name the world save's
// character record carries, which makes a guild the cheap way to read the
// names of players whose saves are not being read.
Name string
// LastOnlineTicks is when the member was last online, measured in the same
// clock as worldSaveData.GameTimeSaveData.RealDateTimeTicks: 100ns ticks of
// real time since the world began, not a date. Two fixture members carry
// exactly the world's current value, which is what identifies the clock --
// they were online when the save was written.
LastOnlineTicks int64
// Role is 1 on the admin of all eight fixture guilds and 3 on the only
// non-admin member. The values themselves are not decoded; the guild trailer
// happens to hold byte-keyed lists for 2, 3 and 4, which is suggestive and
// not evidence.
Role uint8
}
GuildMember is one account in a guild.
type ItemSlot ¶
type ItemSlot struct {
// SlotIndex is the slot's position within its container.
SlotIndex uint32
// Count is the stack size. An occupied slot always has a non-empty ItemID,
// so Count is meaningful only alongside it.
Count uint32
// ItemID is the item's static identifier, such as "Money" or "PalSphere".
// It is empty for an empty slot.
ItemID string
// DynamicItemID keys this stack's entry in
// worldSaveData.DynamicItemSaveData, which holds the per-instance state of
// items that have any: armour and weapon durability, for example. It is the
// zero GUID for the great majority of slots, which hold plain stackables.
DynamicItemID gvas.GUID
// Trailer is the remainder after DynamicItemID, uninterpreted and aliasing
// the caller's slice. It is all zero in 26,572 of 27,094 fixture slots; the
// rest carry a small structure that is not decoded yet, so it is preserved
// rather than dropped.
Trailer []byte
}
ItemSlot is one slot of an item container.
The record is a fixed layout rather than a nested property stream:
uint32 SlotIndex uint32 Count FString ItemID -- length includes the NUL terminator 16 bytes -- zero in every slot of the fixture world 16 bytes DynamicItemID -- zero unless the item has a per-instance record trailer -- usually zero; see Trailer
Verified against every one of the 27,094 slots in the 8,723 containers of a 1.0.1.100619 world save: all 27,094 decode, and all 422 non-zero DynamicItemID values match a DynamicItemSaveData record.
func DecodeItemSlot ¶
DecodeItemSlot decodes one Slots[].RawData payload. The returned ItemSlot aliases data through Trailer, so data must not be modified afterwards.
A blob too short to hold the fixed part is an error rather than a partial result: a truncated slot means the layout assumption is wrong, and silently returning a plausible ItemSlot would hide that.
type RawDataKind ¶
type RawDataKind int
RawDataKind names a RawData layout this package can decode. A path that names no known layout returns RawDataUnknown, which is the honest answer for the blobs still awaiting reverse engineering.
const ( // RawDataUnknown means the layout at this path is not decoded yet. RawDataUnknown RawDataKind = iota // RawDataItemSlot is one slot of an item container: a fixed record ending // in a mostly-zero trailer. See ItemSlot. RawDataItemSlot // RawDataCharacter is a player or pal record: a nested Unreal property // stream followed by a short framing trailer. See Character. RawDataCharacter // RawDataCharacterSlot is one slot of a pal party, storage box, or base camp // worker list: a reference to a character record rather than a record. See // CharacterSlot. RawDataCharacterSlot // RawDataGroup is a guild or an organization: a bespoke record whose second // half is present only for a guild. See Group and Guild. RawDataGroup // RawDataBaseCamp is a guild's base camp: where it is and who owns it. See // BaseCamp. RawDataBaseCamp // RawDataWorkerDirector is a base camp's worker director, which names the // container holding the camp's workers. See WorkerDirector. RawDataWorkerDirector )
func ClassifyRawData ¶
func ClassifyRawData(path string) RawDataKind
ClassifyRawData reports which RawData layout sits at a decoded property path.
func (RawDataKind) String ¶
func (kind RawDataKind) String() string
type Transform ¶
Transform is Unreal's FTransform as a bespoke record writes it: ten float64s, rotation first.
Nothing here normalises or converts. Translation is the useful part -- a base camp's position in the world -- and the rotation is kept because it is how the offsets were confirmed: a wrong split would not produce a unit quaternion, and all 32 transforms in the fixture world's base camps do.
type WorkerDirector ¶
type WorkerDirector struct {
// ID is the base camp this director belongs to, and is that camp's id in all
// 19 fixture records.
ID gvas.GUID
// Transform is a transform whose translation sits inside the camp's area.
Transform Transform
// ContainerID keys worldSaveData.CharacterContainerSaveData: the camp's
// workers. All 19 fixture values resolve to a container, and the 19
// containers are distinct.
ContainerID gvas.GUID
// Reserved is the two bytes before ContainerID, zero in all 19 fixture
// records.
Reserved []byte
// Trailer is the remainder, aliasing the caller's slice.
Trailer []byte
}
WorkerDirector is the WorkerDirector sub-record of a base camp, and the reason phase 4 reads base camps at all: it names the character container holding the camp's workers.
That container is what closes a gap phase 3 left open. Of the fixture world's 3,340 pals, 3,135 sit in a player's party or storage box and 205 sit in nothing a player names. All 205 are in these 19 containers -- so every pal in the world is now accounted for, and by a second route: the guilds' character handles come to the same 3,340.
The record is:
16 bytes Id -- the base camp's own id 80 bytes Transform 2 bytes -- zero in all 19 fixture records 16 bytes ContainerID trailer -- four zero bytes in all 19
func DecodeWorkerDirector ¶
func DecodeWorkerDirector(data []byte) (WorkerDirector, error)
DecodeWorkerDirector decodes one BaseCampSaveData value's WorkerDirector.RawData. The result aliases data.