core

package
v1.5.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 16, 2026 License: GPL-3.0 Imports: 23 Imported by: 0

Documentation

Index

Constants

View Source
const (
	HashMagic   = 0x80078071          // Magic constant for ComputeHashedValue
	HashSize    = 0x80                // 128 bytes = 32 u32 entries (12 used + padding)
	HashOffset  = SlotSize - HashSize // 0x27FF80 — start of hash block in slot
	HashEntries = 12                  // Number of meaningful hash entries
)

Hash algorithm constants.

View Source
const (
	OffHumanity   = -347 // PGD+0x54
	OffSoulMemory = -327 // PGD+0x68
	OffPGD0xB8    = -247 // PGD+0xB8 (1 byte, used for hash index 3)
)

PlayerGameData field offsets relative to MagicOffset, for hash computation. These correspond to the PGD struct layout documented in the audit.

View Source
const (
	OffLevel               = -335
	OffVigor               = -379
	OffMind                = -375
	OffEndurance           = -371
	OffStrength            = -367
	OffDexterity           = -363
	OffIntelligence        = -359
	OffFaith               = -355
	OffArcane              = -351
	OffSouls               = -331
	OffGender              = -249
	OffClass               = -248
	OffGreatRuneOn         = -184 // GreatRuneActive (PGD 0xF7, u8 bool, 0=off 1=on)
	OffTalismanSlots       = -241 // AdditionalTalismanSlotsCount (PGD 0xBE, u8, range 0-3)
	OffScadutreeBlessing   = -187
	OffShadowRealmBlessing = -186
	OffVoiceType           = -245   // Voice type (0=Young1, 1=Young2, 2=Mature1, 3=Mature2, 4=Aged1, 5=Aged2)
	OffCharacterName       = -0x11B // 16 x uint16 UTF-16LE

	// MagicOffset must be at least this value; otherwise negative stat offsets
	// would access memory before the start of the slot buffer.
	MinMagicOffset = 400 // abs(OffVigor) + margin
)

Offsets relative to MagicOffset (negative = before the pattern). Source: SPEC.md §5.2 PlayerGameData.

View Source
const (
	GaRecordWeapon    = 21
	GaRecordArmor     = 16
	GaRecordAccessory = 8
	GaRecordItem      = 8
	GaRecordAoW       = 8
)

GaItem record sizes by handle type prefix (upper nibble). Source: SPEC.md §5.3 GaItems.

View Source
const (
	GaHandleEmpty    = 0x00000000
	GaHandleInvalid  = 0xFFFFFFFF
	GaHandleTypeMask = 0xF0000000 // upper nibble = item type
)

GaItem handle constants.

View Source
const (
	InvStartFromMagic  = 505    // MagicOffset + 505 — points to first common item (common_count header at -4)
	CommonItemCount    = 0xA80  // 2688 common item slots
	KeyItemCount       = 0x180  // 384 key item slots
	StorageItemCount   = 2048   // storage box capacity (read limit for ReadStorage)
	StorageCommonCount = 0x780  // 1920 actual common item slots in storage
	StorageKeyCount    = 0x80   // 128 key item slots in storage
	InvRecordLen       = 12     // bytes per inventory record (handle + qty + index)
	InvSafetyMargin    = 0x9000 // max distance from invStart to validate section
	StorageSafetyMarg  = 0x6000 // max distance from storageStart to validate section
	StorageHeaderSkip  = 4      // skip 4-byte header at StorageBoxOffset
	InvKeyCountHeader  = 4      // 4-byte key_count header between common and key items

	// Offsets of trailing counters relative to (StorageBoxOffset + StorageHeaderSkip).
	// Layout: StorageCommonCount×12 + key_count(4) + StorageKeyCount×12 + next_equip_index(4) + next_acq_sort_id(4)
	StorageNextEquipIdxRel = StorageCommonCount*InvRecordLen + InvKeyCountHeader + StorageKeyCount*InvRecordLen
	StorageNextAcqSortRel  = StorageNextEquipIdxRel + 4
)

Inventory layout (relative to MagicOffset). Source: SPEC.md §5.4 Dynamic Offsets.

View Source
const (
	DynPlayerData           = 0x1B0
	DynSpEffect             = 0xD0
	DynEquipedItemIndex     = 0x58 // these three share the same offset value by coincidence of the save layout
	DynActiveEquipedItems   = 0x1C
	DynEquipedItemsID       = 0x58 // same value as DynEquipedItemIndex — different struct contexts
	DynActiveEquipedItemsGa = 0x58 // same value — GaItem variant of the same offset
	DynInventoryHeld        = 0x9011
	DynEquipedSpells        = 0x74
	DynEquipedItems         = 0x8C
	DynEquipedGestures      = 0x18
	DynEquipedArmaments     = 0x9C
	DynEquipePhysics        = 0x0C
	DynFaceData             = 0x12F
	DynStorageBox           = 0x6010
	DynStorageToGestures    = 0x100
	DynHorse                = 0x29
	DynEquipGreatRune       = 0x28 // offset within EquippedItemsItemIds to Great Rune slot (u32)
	DynClearCount           = 0x44 // offset from horse to ClearCount (NG+ cycle, uint32)
	DynBloodStain           = 0x4C
	DynMenuProfile          = 0x103C
	DynGaItemsOther         = 0x1B588
	DynTutorialData         = 0x40B
	DynIngameTimer          = 0x1A
	DynEventFlags           = 0
)

Dynamic offset chain constants (relative to InventoryEnd). Source: SPEC.md §5.4 Dynamic Offsets.

View Source
const (
	FavBaseOffset = 0x154 // first preset slot in UserData10.Data (same for PC and PS4)
	FavSlotSize   = 0x130 // 304 bytes per preset slot
	FavSlotCount  = 15    // total preset slots in CSMenuSystemSaveLoad

	// Offsets within a single 0x130-byte preset slot
	FavOffBodyFlag  = 0x08 // u8: body flag
	FavOffBodyType  = 0x09 // u8: 0=female, 1=male
	FavOffMarker    = 0x14 // i32: -1 (0xFFFFFFFF) = empty, 0 = active (per er-save-manager)
	FavOffMagic     = 0x18 // "FACE" (4 bytes) — indicates slot is populated
	FavOffAlignment = 0x1C // u32: 4
	FavOffInnerSize = 0x20 // u32: 0x120 (288)
	FavOffModelIDs  = 0x24 // 8 × u32: model IDs (same layout as FaceData blob)
	FavOffFaceShape = 0x44 // 64 bytes: face shape sliders
	FavOffUnkBlock  = 0x84 // 64 bytes: unk0x6c — opaque, preserved on apply (game ignores preset's value)
	FavOffBody      = 0xC4 // 7 bytes: body proportions (head, chest, abdomen, arm_r, leg_r, arm_l, leg_l)
	FavOffSkin      = 0xCB // 91 bytes: skin & cosmetics (same length as slot's FaceData skin block)
)

CSMenuSystemSaveLoad / Favorites preset constants. Located in UserData10.Data. The game's Mirror at Roundtable Hold reads presets from here. All 15 slots (0..14) are safe for use after the ProfileSummary offset fix (ProfileSummary now correctly writes at 0x195E + i*0x24C, well past all preset slots which span 0x154..0x1323). See spec/23-user-data-10.md and spec/31-appearance-presets.md.

View Source
const (
	FavHeaderUnk      = 0x11D0
	FavHeaderMagicU16 = 0xFACE
)

FavHeaderUnk is the constant u32 at preset header offset 0x04 (observed in all active presets). FavHeaderMagicU16 is the u16 written at offset 0x00 of the Favorites slot header.

View Source
const (
	FoWBlobStart = 0x087E
	FoWBlobEnd   = 0x10B0
)

Fog of War bitfield offsets relative to afterRegs. See spec/27-map-reveal.md §4.

View Source
const (
	FaceDataBlobSize = 0x12F // 303 bytes total

	// Header (16 bytes)
	FDOffMarker    = 0x00 // u32 = 0xFFFFFFFF
	FDOffMagic     = 0x04 // "FACE"
	FDOffAlignment = 0x08 // u32 = 4
	FDOffInnerSize = 0x0C // u32 = 0x120 (288)

	// Model IDs (8 × u32, effective u8 + 3 padding each)
	FDOffFaceModel     = 0x10
	FDOffHairModel     = 0x14
	FDOffEyeModel      = 0x18
	FDOffEyebrowModel  = 0x1C
	FDOffBeardModel    = 0x20
	FDOffEyepatchModel = 0x24
	FDOffDecalModel    = 0x28 // tattoo/mark
	FDOffEyelashModel  = 0x2C

	// Face shape parameters (64 × u8, 0x30-0x6F)
	FDOffFaceShape = 0x30

	// Unknown block (64 bytes, 0x70-0xAF) — leave unchanged
	FDOffUnknownBlock = 0x70

	// Body proportions (7 × u8, 0xB0-0xB6)
	FDOffHead    = 0xB0
	FDOffChest   = 0xB1
	FDOffAbdomen = 0xB2
	FDOffArmR    = 0xB3
	FDOffLegR    = 0xB4
	FDOffArmL    = 0xB5
	FDOffLegL    = 0xB6

	// Skin & cosmetics (91 bytes, 0xB7-0x111)
	FDOffSkinR       = 0xB7
	FDOffSkinG       = 0xB8
	FDOffSkinB       = 0xB9
	FDOffSkinLuster  = 0xBA
	FDOffPores       = 0xBB
	FDOffStubble     = 0xBC
	FDOffDarkCircles = 0xBD
	FDOffDarkCircleR = 0xBE
	FDOffDarkCircleG = 0xBF
	FDOffDarkCircleB = 0xC0
	FDOffCheeksInt   = 0xC1
	FDOffCheekR      = 0xC2
	FDOffCheekG      = 0xC3
	FDOffCheekB      = 0xC4
	FDOffEyeliner    = 0xC5
	FDOffEyelinerR   = 0xC6
	FDOffEyelinerG   = 0xC7
	FDOffEyelinerB   = 0xC8
	FDOffEyeShadLow  = 0xC9
	FDOffEyeShadLowR = 0xCA
	FDOffEyeShadLowG = 0xCB
	FDOffEyeShadLowB = 0xCC
	FDOffEyeShadUp   = 0xCD
	FDOffEyeShadUpR  = 0xCE
	FDOffEyeShadUpG  = 0xCF
	FDOffEyeShadUpB  = 0xD0
	FDOffLipstick    = 0xD1
	FDOffLipstickR   = 0xD2
	FDOffLipstickG   = 0xD3
	FDOffLipstickB   = 0xD4
	FDOffTattooH     = 0xD5
	FDOffTattooV     = 0xD6
	FDOffTattooAngle = 0xD7
	FDOffTattooExp   = 0xD8
	FDOffTattooR     = 0xD9
	FDOffTattooG     = 0xDA
	FDOffTattooB     = 0xDB
	FDOffTattooUnk   = 0xDC
	FDOffTattooFlip  = 0xDD
	FDOffBodyHair    = 0xDE
	FDOffBodyHairR   = 0xDF
	FDOffBodyHairG   = 0xE0
	FDOffBodyHairB   = 0xE1
	// Right eye
	FDOffRIrisR    = 0xE2
	FDOffRIrisG    = 0xE3
	FDOffRIrisB    = 0xE4
	FDOffRIrisSize = 0xE5
	FDOffRClouding = 0xE6
	FDOffRCloudR   = 0xE7
	FDOffRCloudG   = 0xE8
	FDOffRCloudB   = 0xE9
	FDOffRWhiteR   = 0xEA
	FDOffRWhiteG   = 0xEB
	FDOffRWhiteB   = 0xEC
	FDOffREyePos   = 0xED
	// Left eye
	FDOffLIrisR    = 0xEE
	FDOffLIrisG    = 0xEF
	FDOffLIrisB    = 0xF0
	FDOffLIrisSize = 0xF1
	FDOffLClouding = 0xF2
	FDOffLCloudR   = 0xF3
	FDOffLCloudG   = 0xF4
	FDOffLCloudB   = 0xF5
	FDOffLWhiteR   = 0xF6
	FDOffLWhiteG   = 0xF7
	FDOffLWhiteB   = 0xF8
	FDOffLEyePos   = 0xF9
	// Hair colors
	FDOffHairR      = 0xFA
	FDOffHairG      = 0xFB
	FDOffHairB      = 0xFC
	FDOffHairLuster = 0xFD
	FDOffHairRoot   = 0xFE
	FDOffHairWhite  = 0xFF
	// Beard colors
	FDOffBeardR      = 0x100
	FDOffBeardG      = 0x101
	FDOffBeardB      = 0x102
	FDOffBeardLuster = 0x103
	FDOffBeardRoot   = 0x104
	FDOffBeardWhite  = 0x105
	// Eyebrow colors
	FDOffBrowR      = 0x106
	FDOffBrowG      = 0x107
	FDOffBrowB      = 0x108
	FDOffBrowLuster = 0x109
	FDOffBrowRoot   = 0x10A
	FDOffBrowWhite  = 0x10B
	// Eyelash colors
	FDOffLashR = 0x10C
	FDOffLashG = 0x10D
	FDOffLashB = 0x10E
	// Eyepatch colors
	FDOffPatchR = 0x10F
	FDOffPatchG = 0x110
	FDOffPatchB = 0x111
)

FaceData blob layout constants. FaceData is a 303-byte (0x12F) block stored at FaceDataOffset-FaceDataBlobSize. All offsets below are relative to the start of the FaceData blob.

View Source
const (
	MaxProjCount      = 200000 // max acquired_projectiles count (projSkip = count*8+4; observed: 67584 PC, 103168 PS4)
	MaxUnlockedRegCnt = 20000  // max unlocked_regions count (regSkip = count*4+4)
	MaxHandleAttempts = 10000  // max iterations for generateUniqueHandle
)

Sanity limits for dynamic size reads from untrusted save data.

View Source
const (
	GaItemDataActiveEntryLen = 8    // active record: itemID(4) + flag(4)
	GaItemDataArrayOff       = 8    // array starts after distinct_count(4) + unk1(4)
	GaItemDataMaxCount       = 7000 // 0x1B58 max active records
)

GaItemData section (distinct_acquired_items_count + GaItem2 array). Source: ER-Save-Editor save_slot.rs, GaItemData struct. GaItemData records every weapon/AoW ID ever acquired. The game looks up weapon properties (reinforce_type etc.) from this list on load. Missing entry → crash.

View Source
const (
	// Offsets relative to afterRegs (end of unlocked regions array).
	DLCTileZeroStart = 0x0088 // start of range to zero out before writing coords
	DLCTileZeroEnd   = 0x0110 // end of range (exclusive)

	// Record 1: DLC map center (2 floats + 1 flag byte)
	DLCTileRec1X    = 0x008D // f32 X coordinate
	DLCTileRec1Y    = 0x0091 // f32 Y coordinate
	DLCTileRec1Flag = 0x0095 // u8 visited flag

	// Record 2: DLC area anchor (4 floats + 1 flag byte)
	DLCTileRec2X    = 0x00C5 // f32 X
	DLCTileRec2Y    = 0x00C9 // f32 Y
	DLCTileRec2Z    = 0x00CD // f32 Z
	DLCTileRec2W    = 0x00D1 // f32 W
	DLCTileRec2Flag = 0x00D5 // u8 visited flag
)

DLC black tile removal constants. Two position records in the BloodStain section control the DLC map cover layer. Writing DLC-area coordinates here removes the black tile overlay. See spec/29-dlc-black-tiles.md for details.

View Source
const (
	DlcSectionSize   = 0x32                                 // 50 bytes
	DlcSectionOffset = SlotSize - HashSize - DlcSectionSize // SlotSize - 0xB2
	DlcEntryFlagByte = 1                                    // byte index within DLC section for SotE entry flag
)

DLC section constants. CSDlc is 0x32 (50) bytes located at SlotSize - 0xB2 (before PlayerGameDataHash). Byte[0] = pre-order gesture "The Ring" Byte[1] = Shadow of the Erdtree entry flag (non-zero = entered DLC; causes infinite loading without DLC) Bytes[2] = pre-order gesture "Ring of Miquella" Bytes[3-49] = must be 0x00

View Source
const (
	GaItemCountOld     = 5118 // 0x13FE — version ≤ 81
	GaItemCountNew     = 5120 // 0x1400 — version > 81
	GaItemVersionBreak = 81   // version threshold for GaItem count change
)

GaItem entry counts by slot version. Source: ER-Save-Editor save_slot.rs, er-save-manager user_data_x.py

View Source
const (
	RepairCodeDuplicateHandle      = "duplicate_handle"
	RepairCodeDuplicateUID         = "duplicate_uid"
	RepairCodeUnknownItemID        = "unknown_item_id"
	RepairCodeUnknownHandleType    = "unknown_handle_type"
	RepairCodeMissingGaItemMapping = "missing_gaitem_mapping"
	RepairCodeQuantityZero         = "quantity_zero"
	RepairCodeQuantityAboveMax     = "quantity_above_max"
	// RepairCodeItemNotAllowedInContainer is emitted instead of
	// quantity_above_max when the effective container cap is zero: the item is
	// not permitted in that container at all, which is a distinct defect from an
	// excessive-but-legal quantity. Splitting the codes keeps the clamp repair
	// from ever driving a quantity down to zero (which would manufacture a new
	// quantity_zero defect).
	RepairCodeItemNotAllowedInContainer = "item_not_allowed_in_container"
	// RepairCodePassThroughRecords is retained for JSON/action-map
	// compatibility but is no longer emitted as an aggregate issue —
	// pass-through is a write strategy, not a defect. Per-record resolution
	// status is reported via the coverage model instead.
	RepairCodePassThroughRecords        = "pass_through_records"
	RepairCodeCurrentAoWMissing         = "current_aow_missing"
	RepairCodeCurrentAoWShared          = "current_aow_shared"
	RepairCodeCurrentAoWNonAoWCategory  = "current_aow_non_aow_category"
	RepairCodeDuplicateAcquisitionIndex = "duplicate_acquisition_index"
	RepairCodeStatsFormula              = "stats_formula"
	// RepairCodeContainerOveruse is a REPORT-ONLY aggregate: the total quantity
	// of pot/aromatic craftables mapped to one container (e.g. all Throwing Pots
	// against Cracked Pot) exceeds the number of that container the slot owns.
	// Per-record caps cannot see this — each individual pot stack may sit within
	// the owned count while their sum overflows the shared container. It carries
	// no mutating action (no safe generic auto-repair; the user chooses what to
	// trim), so its default action is no_action.
	RepairCodeContainerOveruse = "container_overuse"
	// RepairCodeDuplicatePhysicalHandle flags two non-empty records in the
	// physical slot.GaItems table sharing one handle. This is a DISTINCT defect
	// from RepairCodeDuplicateHandle (duplicate Inventory/Storage container
	// records): here the collision is between physical GaItem records that may
	// carry different ItemIDs, which the GaItem repack preflight refuses. It is
	// REPORT-ONLY — no safe generic auto-repair exists (which ItemID to retain is
	// the user's call), so its only action is no_action.
	RepairCodeDuplicatePhysicalHandle = "duplicate_physical_gaitem_handle"
)

Issue code constants — match the codes used in backend/editor/validate.go and in the UI so the frontend can use a single stable key for each problem.

View Source
const (
	RepairActionCreateCopy   = "create_copy"
	RepairActionRemoveRecord = "remove_record"
	RepairActionClearAoW     = "clear_aow"
	RepairActionPickAoW      = "pick_aow"
	RepairActionRepairIndex  = "repair_index"
	RepairActionFixLevel     = "fix_level"
	RepairActionNoAction     = "no_action"
	// RepairActionClampQuantity clamps an over-cap record down to its
	// authoritative effective cap (ClampInventoryQuantityAt).
	RepairActionClampQuantity = "clamp_quantity"
)

Repair action identifiers — proposed by the scanner, executed by the apply endpoint.

View Source
const (
	SectionEmptySlot        = "empty_slot"
	SectionPreUnlockedRegs  = "pre_unlocked_regions"
	SectionUnlockedRegs     = "unlocked_regions"
	SectionPostUnlockedRegs = "post_unlocked_regions"
	SectionDLC              = "dlc"
	SectionHash             = "player_data_hash"
)

Section names emitted by buildSectionMap. Stable identifiers so callers (rebuild logic, tests, diagnostics) can reference sections by name.

View Source
const (
	ActiveSlotsOffset    = 0x1954 // 10 × u8 active-slot flags
	ProfileSummaryOffset = 0x195E // ProfileSummary[i] at base + i*ProfileSummaryStride
	ProfileSummaryStride = 0x24C  // 588 bytes per summary
)

UserData10 per-slot metadata offsets (see spec/23-user-data-10.md).

View Source
const (
	ItemTypeWeapon    = 0x80000000
	ItemTypeArmor     = 0x90000000
	ItemTypeAccessory = 0xA0000000
	ItemTypeItem      = 0xB0000000
	ItemTypeAow       = 0xC0000000
)
View Source
const (
	// NoCustomAoWHandle is the canonical value emitted by writers when a
	// weapon GaItem has no custom Ash of War attached. Matches the value
	// the in-game save writes.
	NoCustomAoWHandle uint32 = 0x00000000

	// LegacyNoCustomAoWHandle is the value historical SaveForge releases
	// (and the GaItemFull zero-value placeholder for empty slots) wrote
	// for the same semantic state. Readers must continue to recognize it
	// so previously edited saves keep working.
	LegacyNoCustomAoWHandle uint32 = 0xFFFFFFFF
)

AoWGaItemHandle sentinel values for "no custom Ash of War attached".

Forensic comparison of in-game vanilla saves (ER0000-kro55-vanilla.sl2 and fresh fields of ER0000.sl2) versus SaveForge-bulk-edited saves shows the game writes 0x00000000 for weapons that have no external AoW gem attached; SaveForge historically wrote 0xFFFFFFFF. The game tolerates both values, but vanilla-aligned output minimizes anti-cheat/validation risk so the writer canonicalizes to NoCustomAoWHandle.

View Source
const (
	SkipReasonInvalidHandle     = "invalid_handle"
	SkipReasonNotFound          = "not_found"
	SkipReasonEquipped          = "equipped"
	SkipReasonDestFull          = "dest_full"
	SkipReasonDestAtCap         = "dest_at_cap"
	SkipReasonMissingCap        = "missing_cap"
	SkipReasonHandleAllocFailed = "handle_alloc_failed"

	// SkipReasonDestDuplicate is reserved. Instance-move handles that
	// encounter the same handle on the destination side trigger a rehandle
	// path (materializeRehandledInstance) and do NOT return this reason. It
	// is kept as a public constant for callers that want to surface a
	// duplicate-handle condition in future direct-write APIs (e.g. preset
	// import) and for backwards compatibility with the prior transfer
	// semantics. The transfer core no longer emits it.
	SkipReasonDestDuplicate = "dest_duplicate"
)

Skip reason constants.

View Source
const (
	TutorialDataHeaderLen = 8    // unk0x0 + unk0x2 + size
	TutorialDataCountOff  = 8    // u32 count after header
	TutorialDataIDsOff    = 12   // first ID after count
	TutorialDataMaxIDs    = 0xFF // (size - 4) / 4 = (0x400 - 4) / 4 = 255
)

TutorialData layout in slot.Data at slot.TutorialDataOffset:

+0x00 (u16): unk0x0
+0x02 (u16): unk0x2
+0x04 (u32): size — total chunk size in bytes (typically 0x400)
+0x08 (u32): count — number of tutorial IDs in the list
+0x0C: count × u32 tutorial IDs (TutorialParam row IDs)

When the game first triggers a tutorial popup (or first hands a tutorial-bound item like an "About *" or Crafting Kit pickup), it appends the corresponding TutorialParam row ID to this list. Subsequent triggers check the list and skip if the ID is already present — so pre-populating the list lets the editor prevent the "About item drops on ground" pickup duplicate.

Source: er-save-manager src/er_save_manager/parser/world.py (TutorialDataChunk). Verified empirically by save diff: buying Crafting Kit at Kalé added ID 2010.

View Source
const BaseVersionSize = 16
View Source
const BloodStainSize = 68
View Source
const ChrAsmEquipmentSize = ChrAsmFieldCount * 4

ChrAsmEquipmentSize is the byte size of the ChrAsmEquipment header (22 × 4 = 0x58).

View Source
const ChrAsmFieldCount = 22

ChrAsmEquipment field count — 22 u32 values per equipment section (0x58 bytes).

View Source
const DLCSectionSerializedSize = 50
View Source
const EquippedSpellEmptySentinel uint32 = 0xFFFFFFFF

EquippedSpellEmptySentinel marks an empty spell slot. Both spell_id == 0xFFFFFFFF AND follower == 0x00000000 are required by the game; mixing these is a corrupt state.

View Source
const EquippedSpellOccupiedFollower uint32 = 0xFFFFFFFF

EquippedSpellOccupiedFollower is written into the follower/unk u32 for any occupied slot. The lower-level meaning of this field (follower toggle / unk) is not relevant here; vanilla saves consistently use 0xFFFFFFFF for every occupied spell slot regardless of which spell is equipped.

View Source
const EquippedSpellSlotCount = 14

EquippedSpellSlotCount is the number of spell slots in the EquippedSpells section (memory + skills shortcuts). Mirrors the 14-slot constant used by readSpellIDs in hash.go.

View Source
const EquippedSpellSlotSize = 8

EquippedSpellSlotSize is the per-slot byte stride: spell_id u32 LE followed by follower/unk u32 LE.

View Source
const EventFlagsBlockSize = EventFlagsByteCount + 1
View Source
const EventFlagsByteCount = 0x1BF99F

EventFlagsByteCount is the fixed length of the event_flags bitfield in every save slot. Reference: tmp/repos/er-save-manager/parser/user_data_x.py:374

View Source
const FallbackMagicBase = 0x15420 + 432

FallbackMagicBase is the hardcoded base used when MagicPattern is not found.

View Source
const (
	GaItemsStart = 0x20 // scan starts here
)

GaItems section. Source: SPEC.md §5.3 GaItems.

View Source
const GaitemGameDataSize = 8 + gaitemEntryCount*gaitemEntrySize // 0x1B458
View Source
const InvEquipReservedMax = 432

InvEquipReservedMax is a conservative lower bound for SaveForge-generated fresh indices. New records written by the editor should stay above this floor and above existing indices to avoid collisions with game-managed entries.

This is not a validation rule for existing records: genuine saves can contain game-created inventory/storage records with Index <= InvEquipReservedMax.

View Source
const InvUnarmedBaseID = uint32(0x0001ADB0)

InvUnarmedBaseID is the "Unarmed" placeholder weapon base itemID the game keeps as a technical inventory slot (row 110000 in EquipParamWeapon). It is a resolved technical placeholder, never an unknown item.

View Source
const MinSaveFileSize = 10*SlotSize + 0x60000

MinSaveFileSize is the minimum valid save file size: 10 slots × 0x280000 + UserData10 (0x60000).

View Source
const NetManSize = 4 + 0x20000
View Source
const PS5ActivitySize = 0x20
View Source
const PlayerCoordinatesSize = 12 + 4 + 16 + 1 + 12 + 16
View Source
const PlayerGameDataHashSize = 11*4 + 0x54 // 128
View Source
const PreEventFlagsScalarsSize = 3 + 4 + 4 + 1 + 4 + 4 + 1 + 4 + 4 // 29
View Source
const RideGameDataSize = 40
View Source
const SlotSize = 0x280000

SlotSize is the fixed size of each save slot in bytes (2,621,440 = 0x280000). Source: SPEC.md §3.1 BND4 Container.

View Source
const TrophyEquipDataSize = 52
View Source
const UnsupportedContainerCode = "ERR_UNSUPPORTED_CONTAINER"

UnsupportedContainerCode is a stable, machine-matchable discriminator prefixed to ErrUnsupportedContainer's message so the frontend can reliably detect this specific failure (and show a dedicated modal) without matching prose.

View Source
const WorldAreaTimeSize = 12
View Source
const WorldAreaWeatherSize = 12
View Source
const WorldHeadSize = RideGameDataSize + 1 + BloodStainSize + 4 + 4

Variables

View Source
var ErrUnsupportedContainer = fmt.Errorf("%s: this file's save format could not be identified safely (not a native PC or PS4 save). It will not be opened, to avoid writing it back in the wrong platform format. Format conversion is currently unavailable", UnsupportedContainerCode)

ErrUnsupportedContainer is returned by LoadSave when the input container is not an unambiguous native save (raw BND4 for PC, raw PS4 magic for PS4). User-facing so the frontend can explain why the file was not opened.

View Source
var MagicPattern = []byte{
	0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
	0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
	0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
	0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
}

MagicPattern matches the 192-byte pattern used in the Python editor for reliability. First block: 0x00 + 0xFFFFFFFF + 12 zeros (17 bytes) Subsequent blocks: 0xFFFFFFFF + 12 zeros (16 bytes each)

View Source
var (
	// SaveKey is the standard Elden Ring PC Save AES-128-CBC key.
	SaveKey = []byte{0x99, 0xad, 0x2d, 0x50, 0xed, 0xf2, 0xfb, 0x01, 0xc5, 0xf3, 0xec, 0x3a, 0x2b, 0xca, 0xb6, 0x9d}
)

Functions

func AddItemsToSlot

func AddItemsToSlot(slot *SaveSlot, itemIDs []uint32, invQty, storageQty int, forceStackable bool) error

AddItemsToSlot adds multiple items to a specific save slot. invQty and storageQty control quantities: 0 = skip, -1 = use provided max from caller, >0 = exact qty. forceStackable treats items as stackable (reuse existing GaMap handle) regardless of type. Used for arrows/bolts which have weapon-like IDs but are stackable in inventory.

Algorithm (Plan D — GaItems Section Re-serialization):

Phase 1: Allocate GaItem entries in-memory array + write GaItemData at old offsets.
Phase 2: FlushGaItems — serialize array, compute size delta, single shift, update offsets.
Phase 3: Add to inventory/storage (offsets now correct after flush).

func AddItemsToSlotBatch

func AddItemsToSlotBatch(slot *SaveSlot, items []ItemToAdd) error

AddItemsToSlotBatch adds a batch of items with per-item qty/stackable settings. All GaItem allocations happen in Phase 1, then ONE RebuildSlotFull in Phase 2, then all inventory/storage writes in Phase 3. This is O(1) rebuilds instead of O(N).

func AppendTutorialID

func AppendTutorialID(slot *SaveSlot, id uint32) error

AppendTutorialID adds a tutorial ID to the slot's TutorialData list. Idempotent: if the ID already exists, returns nil without modification. Returns an error if the list is full (255 IDs) or offset chain failed.

func ApplyGaItemRepackPlan added in v1.5.0

func ApplyGaItemRepackPlan(slot *SaveSlot, plan GaItemRepackPlan) error

ApplyGaItemRepackPlan replaces only slot.GaItems with a private copy of the planned stable layout. It intentionally does not rebuild bytes, reparse, update cursors, or touch GaMap; those atomic transaction steps belong to the caller. A no-op plan does not replace the existing slice.

func AttachExistingWeaponAoW added in v1.1.0

func AttachExistingWeaponAoW(slot *SaveSlot, weaponHandle, aowHandle uint32) error

AttachExistingWeaponAoW attaches an existing, free AoW GaItem (identified by its 0xC0 handle) to the weapon. Backs attach_existing_aow. Compatibility is validated against the exact weapon before mutation; PatchWeaponAoWHandle then enforces that the AoW GaItem exists and is not already referenced by another weapon, and writes exactly 4 bytes on success — so a rejection never mutates.

func ClearProfileSummaryRegion added in v1.0.0

func ClearProfileSummaryRegion(data []byte, idx int)

ClearProfileSummaryRegion zeroes the FULL ProfileSummary region for slot idx in UserData10.Data — name, level, AND the opaque face/equipment snapshot (ProfileSummaryStride bytes). ProfileSummary.Serialize only rewrites name+level, so without this a deleted slot would keep the previous occupant's face/equipment snapshot, which the character-select menu could still render as a phantom.

func ClearWeaponAoW added in v1.1.0

func ClearWeaponAoW(slot *SaveSlot, weaponHandle uint32) error

ClearWeaponAoW removes the Ash of War from a weapon, writing the canonical no-custom sentinel in-place. Backs the clear_aow action for all three current_aow_* codes. Always legal — no compatibility check needed.

func ComputeMD5

func ComputeMD5(data []byte) [16]byte

ComputeMD5 calculates the MD5 checksum of the given data.

func ComputeSlotHash

func ComputeSlotHash(slot *SaveSlot) [HashSize]byte

ComputeSlotHash calculates the full CSPlayerGameDataHash (0x80 bytes) for a slot. The hash block is written at SlotSize - 0x80.

The offset chain for equipment sections mirrors calculateDynamicOffsets() in structures.go, including the dynamic projSize field. This ensures hash entries [7]-[10] read from the correct positions in the slot data.

Hash entries:

[0]  Level
[1]  Stats (with Int/Faith swapped)
[2]  ArcheType (Class)
[3]  PGD+0xB8 byte
[4]  padding (0)
[5]  Souls
[6]  SoulMemory
[7]  EquippedWeapons (10 IDs)
[8]  EquippedArmors (4 armor + 5 talismans = 9 IDs)
[9]  EquippedItems (16 quick/pouch IDs, lower 28 bits)
[10] EquippedSpells (14 spell IDs)
[11] padding (0)

func CreateBackup

func CreateBackup(path string) (string, error)

CreateBackup creates a copy of the file at the given path. The backup file is named: original_filename.YYYYMMDD_HHMMSS.bak Returns the path to the created backup file.

func CreateWeaponAoWCopy added in v1.1.0

func CreateWeaponAoWCopy(slot *SaveSlot, weaponHandle, aowItemID uint32) error

CreateWeaponAoWCopy allocates a fresh AoW GaItem for aowItemID and attaches it to the weapon. Backs create_new_aow_copy (default for current_aow_shared). Compatibility and GaItems/GaItemData capacity are prechecked read-only before PatchWeaponAoW runs, so an incompatible AoW or a full slot reports an error with no partial mutation.

func CurrentWeaponAoWItemID added in v1.1.0

func CurrentWeaponAoWItemID(slot *SaveSlot, weaponHandle uint32) (uint32, bool)

CurrentWeaponAoWItemID resolves the itemID of the Ash of War currently attached to weaponHandle, via the weapon GaItem's AoWGaItemHandle → GaMap. ok=false when the weapon is absent, carries no custom AoW, or the AoW handle is unmapped. Used by the repair apply endpoint to derive the copy source for create_new_aow_copy on a shared AoW without asking the UI to supply it.

func DecryptSave

func DecryptSave(data []byte) ([]byte, error)

DecryptSave decrypts the AES-128-CBC encrypted payload from a PC save. The first 16 bytes of the encrypted data are used as the IV.

func EffectiveQuantityCap added in v1.1.0

func EffectiveQuantityCap(rec ResolvedRecord, containerOwned map[uint32]uint64) (limit uint64, applies bool)

EffectiveQuantityCap returns the authoritative per-record quantity cap for a resolved record. It is the single source of quantity-cap semantics for both the scanner and the clamp repair primitive, so the two can never disagree.

applies is false — and limit is 0 — for any record that carries no authoritative cap: a record that did not resolve to a DB entry (unknown / technical placeholder) or one in an unrecognised scope. Callers must not category-check or clamp such a record.

For a KnownDB record, storage uses GameMaxStorage. Inventory uses GameMaxInventory EXCEPT for pot/aromatic craftables listed in data.RequiredContainer: the game caps those by the runtime container limit, not by their raw maxNum. For those, the inventory cap is how many of the required container the slot currently owns (containerOwned[containerID]), which is 0 when the container is missing. Storage is never container-capped.

containerOwned maps containerItemID -> owned inventory quantity; pass nil when no record in scope is container-gated (the map lookup then yields 0). This deliberately does not use the conservative Normal Mode caps or scales_with_ng: editor policy and single-playthrough availability are not save-integrity truths.

A known zero is a legitimate value (item not permitted in the container, or no container owned) and still returns applies=true.

func EncryptSave

func EncryptSave(data []byte, iv []byte) ([]byte, error)

EncryptSave encrypts the payload using AES-128-CBC. It returns the IV prepended to the encrypted data.

func FingerprintRecordAt added in v1.1.0

func FingerprintRecordAt(slot *SaveSlot, scope string, row int) (string, bool)

FingerprintRecordAt returns the fingerprint of the inventory/storage record currently at scope+row, so a repair apply endpoint can stale-check a target against the state captured at scan time before dispatching a primitive. ok=false when scope is unknown or row is out of range.

func FlushGaItems deprecated

func FlushGaItems(slot *SaveSlot) error

FlushGaItems serializes the entire in-memory GaItems array back to slot.Data. If the total byte size changed (e.g. empty 8B slot replaced by 21B weapon), shifts all data after the GaItems section and updates all downstream offsets.

Deprecated: this function uses an in-place data shift that overwrites the last `delta` bytes of slot.Data — including DLC section (50 B at SlotSize-0xB2) and PlayerGameDataHash (128 B at SlotSize-0x80) — whenever delta > 0x132. Use RebuildSlotFull (called from AddItemsToSlot Phase 2) instead. Kept for backward compatibility with any external callers; will be removed once no callers remain. See CHANGELOG entry for the FlushGaItems DLC+Hash overwrite post-mortem.

func GaItemRecordSize

func GaItemRecordSize(itemID uint32) int

GaItemRecordSize returns the byte size of a GaItem record based on item_id. Uses item_id (not handle) for size determination, matching Rust ER-Save-Editor.

func HasTutorialID

func HasTutorialID(slot *SaveSlot, id uint32) (bool, error)

HasTutorialID returns true if the given tutorial ID is already in the list.

func IsHandleEquipped added in v1.0.0

func IsHandleEquipped(slot *SaveSlot, handle uint32) bool

IsHandleEquipped reports whether the given handle is referenced by any slot of ChrAsmEquipment (equipped weapons / armor / talismans / arrows). The equipped block stores item-form IDs, so the check matches against multiple candidate representations of the handle:

  • handle itself (defensive — some saves may store handles directly)
  • GaMap[handle] (true item ID for weapons, armor, AoW)
  • lower 28 bits with item-ID prefix (talismans 0xA0→0x20, goods 0xB0→0x40)

Returns false when EquipItemsIDOffset is not parsed.

func IsNoCustomAoWHandle added in v1.0.0

func IsNoCustomAoWHandle(h uint32) bool

IsNoCustomAoWHandle reports whether the given AoWGaItemHandle field value means "no external Ash of War attached" — covering both the canonical vanilla sentinel (0x00000000) and the legacy SaveForge sentinel (0xFFFFFFFF). Use this in every reader and availability scan; any check that compares against a single sentinel will misclassify half of the save population.

func IssueKeyID added in v1.1.0

func IssueKeyID(key IssueKey) string

IssueKeyID computes the stable issueID for a given IssueKey (hex SHA-256[:8]).

func PatchEquippedSpell added in v1.0.0

func PatchEquippedSpell(slot *SaveSlot, slotIndex int, spellID uint32) error

PatchEquippedSpell writes a single spell slot in the EquippedSpells section of slot.Data.

spellID is the raw MagicParam ID (e.g. Catch Flame = 0x1770), NOT a full item ID (the 0x40XXXXXX / 0x60XXXXXX prefixed form used elsewhere in the save). Passing EquippedSpellEmptySentinel (0xFFFFFFFF) clears the slot.

Semantics:

spellID == 0xFFFFFFFF → write (spell_id=0xFFFFFFFF, follower=0x00000000)
spellID != 0xFFFFFFFF → write (spell_id=spellID,   follower=0xFFFFFFFF)

Out of scope: this writer does NOT touch the slot hash block. Callers that want the in-save hash refreshed must invoke ComputeSlotHash separately, the same way other low-level core writers (PatchWeaponItemID, etc.) leave the hash update to the apply layer.

Errors are returned WITHOUT mutating slot.Data. Idempotent writes (target bytes already match) are skipped.

func PatchNetworkParams

func PatchNetworkParams(ud11 []byte, patch NetworkParamValues) ([]byte, error)

PatchNetworkParams modifies NetworkParam in UserData11 and returns the patched UserData11.

func PatchWeaponAoW added in v1.0.0

func PatchWeaponAoW(slot *SaveSlot, weaponHandle, newAoWItemID uint32) error

PatchWeaponAoW sets or removes the Ash of War attached to a weapon GaItem.

newAoWItemID == 0: removes AoW — patches AoWGaItemHandle to the canonical NoCustomAoWHandle (0x00000000) in-place. No GaItem allocation, no RebuildSlotFull.

newAoWItemID != 0: allocates a fresh AoW GaItem (never reuses an existing handle — sharing an AoW handle between two weapons causes EXCEPTION_ACCESS_VIOLATION), upserts GaItemData, calls RebuildSlotFull + parseFromData, then patches the weapon's AoWGaItemHandle field after the rebuild settles offsets.

Old AoW GaItems are intentionally left in place — the game tolerates orphaned entries.

This is the allocate path: it mints a fresh AoW GaItem rather than reusing an existing handle. It is invoked by the active workspace save flow (backend/editor/save.go) when an AoW change requires a new record; the in-place strict path (PatchWeaponAoWHandle) handles reuse of a pre-existing free copy.

func PatchWeaponAoWHandle added in v1.0.0

func PatchWeaponAoWHandle(slot *SaveSlot, weaponHandle uint32, newAoWHandle uint32) error

PatchWeaponAoWHandle patches the AoWGaItemHandle field of a weapon GaItem in-place. newAoWHandle indicates no-custom-AoW (per IsNoCustomAoWHandle): removes AoW attachment. Always allowed; the canonical NoCustomAoWHandle value is written regardless of which sentinel the caller supplied — keeps disk output vanilla-aligned even when legacy callers still pass 0xFFFFFFFF. newAoWHandle is a valid 0xC0... handle: attaches an existing AoW GaItem — validates that the handle identifies an existing AoW GaItem and is not already referenced by a different weapon. No GaItem allocation, no RebuildSlotFull — exactly 4 bytes at [weaponOff+16] are overwritten.

func PatchWeaponItemID added in v1.0.0

func PatchWeaponItemID(slot *SaveSlot, handle, expectedCurrentItemID, newItemID uint32) error

PatchWeaponItemID changes the ItemID of a single weapon GaItem in-place. Both IDs must be in the weapon range (prefix 0x00000000 → record size 21 B). Since the record size doesn't change, no RebuildSlotFull is needed — we overwrite exactly 4 bytes at the ItemID field and update derived state.

Caller guarantees:

  • handle identifies one weapon instance in slot.GaItems / slot.GaMap
  • expectedCurrentItemID matches what the slot currently stores (stale-data guard)
  • newItemID encodes the same base weapon + same upgrade level, different infusion

func PruneBackups

func PruneBackups(path string, max int) error

PruneBackups removes oldest timestamped backups for a given file, keeping at most max versions. Backup filenames are expected to match the pattern: <path>.<YYYYMMDD_HHMMSS>.bak

func ReadTutorialIDs

func ReadTutorialIDs(slot *SaveSlot) ([]uint32, error)

ReadTutorialIDs returns the list of tutorial IDs currently registered in the slot's TutorialData block. Returns empty slice + error if offset is invalid.

func RebuildSlot

func RebuildSlot(slot *SaveSlot) ([]byte, error)

RebuildSlot serializes a SaveSlot into a fresh 0x280000-byte buffer.

Sequential rebuild strategy (Option B / R-1 final):

  1. Copy bytes [0, UnlockedRegionsOffset) verbatim (pre-regions blob).
  2. Reserialize `unlocked_regions` from slot.UnlockedRegions (count u32 + N×u32). This is the only section whose size may change.
  3. Re-parse every section after `unlocked_regions` from slot.Data starting at the *original* regions end, then write each one back via its typed Write method. Sections written: WorldHead, MenuSaveLoad, TrophyEquipData, GaitemGameData, TutorialData, PreEventFlagsScalars, EventFlagsBlock, WorldGeomBlock, PlayerCoordinates, SpawnPointBlock, NetMan, TrailingFixedBlock (weather/time/base/steam/ps5/dlc), PlayerGameDataHash.
  4. Pad the remainder of the slot with zeros up to SlotSize. This tail padding absorbs the unlocked_regions delta on saves that have slack (PC saves observed with ~419KB rest; PS4 saves with 0).

For an unmodified slot this produces byte-for-byte identical output to slot.Data. For a mutated UnlockedRegions slice it produces a save where every other section retains its original bytes, only the regions block shifts size, and the tail rest absorbs the delta.

Reference: tmp/repos/er-save-manager/src/er_save_manager/parser/slot_rebuild.py

func RebuildSlotFull

func RebuildSlotFull(slot *SaveSlot) ([]byte, error)

RebuildSlotFull rebuilds the slot from scratch, including a fresh GaItems section serialized from slot.GaItems. This replaces the in-place data shift in FlushGaItems which overwrote the last `delta` bytes of slot.Data — the DLC section (50 B) and PlayerGameDataHash (128 B) — whenever GaItems grew by more than 0x132 bytes.

Layout produced (all written into a fresh SlotSize buffer):

  1. Header : slot.Data[0:GaItemsStart] (32 bytes)
  2. GaItems : serialized slot.GaItems (variable)
  3. PreRegs : slot.Data[oldGaLimit:slot.UnlockedRegionsOffset] (variable, verbatim)
  4. Regions : count u32 + N×u32 (variable)
  5. PostRegs: WorldHead..Hash (typed Write sequence) (~263 KB)
  6. TailPad : zeros up to SlotSize

DLC + PlayerGameDataHash are preserved because they're written via TrailingFixedBlock.Write / PlayerGameDataHash.Write from struct fields parsed at slot.Read() time. As long as those parsed values are intact in the in-memory SaveSlot, the rebuild restores them at the correct fixed end-of-slot positions.

Caller responsibility: after calling this function and copying the result over slot.Data, the caller MUST refresh derived state via:

slot.calculateDynamicOffsets(); slot.mapInventory(); slot.buildSectionMap()

Reference: tmp/repos/ER-Save-Editor/src/save/common/save_slot.rs (Rust reference uses a similar full rebuild on every mutation).

func RecalculateSlotHash

func RecalculateSlotHash(slot *SaveSlot)

RecalculateSlotHash computes and writes the CSPlayerGameDataHash into slot data.

func ReconcileInventoryHeader

func ReconcileInventoryHeader(slot *SaveSlot)

ReconcileInventoryHeader sets the held-inventory common_item_count header to the actual number of non-empty common item slots. Mirrors ReconcileStorageHeader. Call this after loading a save that was edited by another tool (er-save-manager, Rust ER-Save-Editor) to guarantee the counter matches what the game will see.

func ReconcileStorageHeader

func ReconcileStorageHeader(slot *SaveSlot)

ReconcileStorageHeader sets the storage header count to the actual number of non-empty items in the storage array. Fixes mismatch from blind +1 increments in addToInventory when the header was already wrong.

func RemoveInventoryRecordAt added in v1.1.0

func RemoveInventoryRecordAt(slot *SaveSlot, scope string, row int, fingerprint string) error

RemoveInventoryRecordAt deletes exactly ONE inventory or storage record, identified structurally by scope + row (matching the RepairIssue IssueKey) plus a fingerprint stale-check.

Unlike RemoveItemFromSlot — which zeroes EVERY record sharing a handle — this targets a single record by position. Duplicate talisman copies (0xA0) and any other record that happens to share the same handle in a different row are left untouched.

scope is one of the repairScope* constants (inventory_common, inventory_key, storage_common). row is the index into the matching in-memory list exactly as the scanner produced it. fingerprint must equal fingerprintInventoryItem of the record currently at that row; a mismatch means the slot changed since the scan, so the removal is refused with no mutation.

GaItem records are intentionally NOT garbage-collected: another record may still reference the same handle/GaItem. Call RepairOrphanedGaItems separately when GC is explicitly wanted.

func RemoveItemByBaseID

func RemoveItemByBaseID(slot *SaveSlot, itemID uint32)

RemoveItemByBaseID removes an item from inventory by its base item ID (e.g. 0x40002198). Editor-added goods use computed handle (e.g. 0xB0XXXXXX for 0x40XXXXXX items). Game-placed key items in the KeyItems section use the raw item ID as handle.

func RemoveItemFromSlot

func RemoveItemFromSlot(slot *SaveSlot, handle uint32, fromInventory, fromStorage bool) error

RemoveItemFromSlot zeroes out inventory/storage slots for the given handle. Inventory: fixed pre-allocated array — zero the matching slot(s). Storage: dynamic list — zero the matching slot(s); game stops reading at handle==0. GaMap entry is removed only when the handle is absent from both lists after removal.

func RepairDLCSection added in v1.0.0

func RepairDLCSection(slot *SaveSlot) bool

RepairDLCSection zeros the reserved trailing bytes (indices 3–49) of the DLC section. Returns true if any byte was changed.

func RepairDuplicateWondrousPhysick added in v1.0.0

func RepairDuplicateWondrousPhysick(slot *SaveSlot) (int, error)

func RepairGaItemDataCount added in v1.0.0

func RepairGaItemDataCount(slot *SaveSlot) bool

RepairGaItemDataCount caps the GaItemData count header to the allowed maximum. Returns true if the value was changed.

func RepairGaItemDuplicate added in v1.5.0

func RepairGaItemDuplicate(slot *SaveSlot, handle uint32, keepIndex int) error

RepairGaItemDuplicate removes only the unselected physical GaItem record for a verified duplicate pair, preserving the user-selected record unchanged. It runs as one transaction: any failed postcondition restores the complete slot, so a refused or failed call leaves the slot byte-for-byte and structurally unchanged. It never saves a file, never creates a backup, and never runs a repack.

func RepairOrphanedGaItems

func RepairOrphanedGaItems(slot *SaveSlot) int

RepairOrphanedGaItems clears GaItem records whose handles are not present in either held inventory or storage. These orphans accumulate when RemoveItemFromSlot zeros the inventory slot but does not clear the backing GaItem binary record — scanGaItems() then re-adds them to GaMap on every load.

Returns the number of entries cleared.

func RepairSlot added in v1.0.0

func RepairSlot(slot *SaveSlot) (fixed, skipped []string)

RepairSlot applies all available automated repairs to the slot. Returns lists of what was fixed and what was skipped (unrepairable).

func RepairStats added in v1.0.0

func RepairStats(slot *SaveSlot) []string

RepairStats clamps player level and attributes to valid game ranges. Calls SyncPlayerToData on any change. Returns list of applied fixes.

func RepairStorageCountHeader added in v1.0.0

func RepairStorageCountHeader(slot *SaveSlot) bool

RepairStorageCountHeader recalculates the storage box item count header. Returns true if the header was corrected.

func RestoreSlot

func RestoreSlot(slot *SaveSlot, snap SlotSnapshot)

RestoreSlot overwrites all mutable slot state from a snapshot.

func ScanRepairIssuesWithCoverage added in v1.1.0

func ScanRepairIssuesWithCoverage(slotIndex int, slot *SaveSlot, records []ResolvedRecord) ([]RepairIssue, ValidationCoverage)

ScanRepairIssuesWithCoverage runs the full scan over a pre-resolved record collection and returns both the issues and a coverage report whose StructuralChecksApplied reflects the records the structural scanner actually processed — not a count the coverage builder assumed. This is the pipeline entry point: it guarantees structural coverage is only reported AFTER the scanner has run. Read-only.

func SetUnlockedRegions

func SetUnlockedRegions(slot *SaveSlot, ids []uint32) error

SetUnlockedRegions replaces the slot's unlocked-regions list with the given IDs (deduplicated, sorted ascending), rebuilds the slot data via RebuildSlot, and refreshes dynamic offsets so subsequent reads/writes see the new layout.

This is the write entry point for the Invasion Regions feature. The rebuild path means the call always succeeds regardless of how much "slack" exists at the slot tail (full struct rebuild + tail zero pad).

func UTF16ToString

func UTF16ToString(u16 []uint16) string

func ValidateNetworkParams

func ValidateNetworkParams(p NetworkParamValues) error

ValidateNetworkParams checks all field boundaries. Returns nil if valid.

func ValidateSlotIntegrity

func ValidateSlotIntegrity(slot *SaveSlot) error

ValidateSlotIntegrity performs write-ahead validation on a slot before saving. It re-checks the offset chain, inventory bounds, data length and stat sanity to prevent writing a corrupted save file.

Types

type AoWCopyRaw added in v1.0.0

type AoWCopyRaw struct {
	ItemID                  uint32 // AoW item ID (upper nibble 0x8)
	Handle                  uint32 // AoW GaItem handle (upper nibble 0xC)
	UsedByWeaponHandle      uint32 // 0 if this copy is free; weapon handle if attached
	HasSharedHandleConflict bool   // true if more than one weapon references this handle
}

AoWCopyRaw is one Ash of War GaItem found in the save slot.

func ScanAoWAvailability added in v1.0.0

func ScanAoWAvailability(slot *SaveSlot) []AoWCopyRaw

ScanAoWAvailability scans slot.GaItems and returns one AoWCopyRaw per AoW GaItem found.

Pass 1 collects every AoW GaItem (handle prefix 0xC0000000) and every weapon's AoWGaItemHandle reference. Pass 2 cross-references them to determine which copies are free and whether any handle is shared by multiple weapons (save corruption indicator).

Assumptions:

  • One AoW itemID may have multiple copies (different handles).
  • A handle must not be shared between two weapons; if it is, both copies are flagged.
  • Only entries where !g.IsEmpty() are considered.

type BaseVersion

type BaseVersion struct {
	BaseVersionCopy uint32
	BaseVersion     uint32
	IsLatestVersion uint32
	Unk0xc          uint32
}

BaseVersion — 16 bytes (4×u32).

func (*BaseVersion) Read

func (b *BaseVersion) Read(r *Reader) error

func (*BaseVersion) Write

func (b *BaseVersion) Write(sw *SectionWriter)

type BloodStain

type BloodStain struct {
	Coordinates FloatVector3
	Angle       FloatVector4
	Unk1c       uint32
	Unk20       uint32
	Unk24       uint32
	Unk28       uint32
	Unk2c       uint32
	Unk30       int32
	Runes       int32
	MapID       MapID
	Unk3c       uint32
	Unk38       uint32
}

BloodStain — death drop. 0x44 = 68 bytes. Layout: coordinates(12) | angle(16) | 5×u32 | i32×2 | map_id(4) | u32×2 Reference: tmp/repos/er-save-manager/parser/world.py:BloodStain

func (*BloodStain) Read

func (b *BloodStain) Read(r *Reader) error

func (*BloodStain) Write

func (b *BloodStain) Write(w *SectionWriter)

type CSMenuSystemSaveLoad

type CSMenuSystemSaveLoad struct {
	Data []byte
}

func (*CSMenuSystemSaveLoad) Read

func (c *CSMenuSystemSaveLoad) Read(r *Reader)

type CapacityReport

type CapacityReport struct {
	CanFitAll        bool
	CapHit           string // "" | "inventory_full" | "storage_full" | "gaitem_full" | "gaitemdata_full"
	FreeInv          int
	FreeStorage      int
	FreeGaItems      int
	FreeGaItemCursor int // allocator cursor room: len(GaItems) - NextArmamentIndex
	FreeGaItemData   int
	NeededInv        int
	NeededStorage    int
	NeededGaItems    int
	NeededGaItemData int
}

CapacityReport describes why items don't fit.

func CheckAddCapacity

func CheckAddCapacity(slot *SaveSlot, items []ItemToAdd) CapacityReport

CheckAddCapacity verifies that ALL items can be added without exceeding any container limit. Returns a report indicating whether everything fits.

type DLCSection

type DLCSection struct {
	PreorderTheRing        uint8
	ShadowOfErdtreeFlag    uint8 // SotE entry flag (non-zero = entered DLC)
	PreorderRingOfMiquella uint8
	Unused                 [47]byte
}

DLCSection — 50 bytes. Layout: pre_order_the_ring u8 | shadow_of_erdtree u8 |

pre_order_ring_of_miquella u8 | unused [47]byte

func (*DLCSection) Read

func (d *DLCSection) Read(r *Reader) error

func (*DLCSection) Write

func (d *DLCSection) Write(sw *SectionWriter)

type DiagnosticIssue

type DiagnosticIssue struct {
	Severity    DiagnosticSeverity `json:"severity"`
	Category    string             `json:"category"` // "offset_chain", "gaitem", "inventory", "stats", "dlc"
	Description string             `json:"description"`
}

DiagnosticIssue represents a single corruption finding.

type DiagnosticSeverity

type DiagnosticSeverity string

DiagnosticSeverity classifies corruption findings.

const (
	SeverityCritical DiagnosticSeverity = "critical" // save will crash the game
	SeverityWarning  DiagnosticSeverity = "warning"  // save may behave unexpectedly
	SeverityInfo     DiagnosticSeverity = "info"     // observation, not necessarily harmful
)

type DuplicateInventoryIndexIssue added in v1.0.0

type DuplicateInventoryIndexIssue struct {
	Index           uint32 `json:"index"`
	Scope           string `json:"scope"` // "inventory_common" | "inventory_key"
	FirstRow        int    `json:"firstRow"`
	FirstHandle     uint32 `json:"firstHandle"`
	DuplicateRow    int    `json:"duplicateRow"`
	DuplicateHandle uint32 `json:"duplicateHandle"`
}

DuplicateInventoryIndexIssue describes a single Index collision discovered by ScanDuplicateInventoryIndices. Used by pre-flight guards to abort mutations on already-corrupt saves with a precise diagnostic instead of a misleading post-mutation rollback.

func ScanDuplicateInventoryIndices added in v1.0.0

func ScanDuplicateInventoryIndices(slot *SaveSlot) []DuplicateInventoryIndexIssue

ScanDuplicateInventoryIndices walks Inventory.CommonItems and Inventory.KeyItems and reports every Index value that appears more than once across the combined inventory list. Empty / invalid handles are ignored. Storage is not scanned — duplicate post-mutation validation only covers inventory.

Read-only: never modifies slot. Safe to call before snapshot/mutation as a pre-flight guard.

type EquipInventoryData

type EquipInventoryData struct {
	CommonItems           []InventoryItem
	KeyItems              []InventoryItem
	NextEquipIndex        uint32
	NextAcquisitionSortId uint32
	// contains filtered or unexported fields
}

func (*EquipInventoryData) Clone

Clone returns a deep copy of EquipInventoryData, including unexported offset fields.

func (*EquipInventoryData) NextEquipIndexOff

func (e *EquipInventoryData) NextEquipIndexOff() int

NextEquipIndexOff returns the absolute byte offset of NextEquipIndex in slot.Data. Used by tests to exclude intentionally-corrected bytes from round-trip comparison.

func (*EquipInventoryData) Read

func (e *EquipInventoryData) Read(r *Reader, commonCount, keyCount int) error

func (*EquipInventoryData) ReadStorage

func (e *EquipInventoryData) ReadStorage(r *Reader, count int) error

type EquipmentSlotKind added in v1.0.0

type EquipmentSlotKind int

EquipmentSlotKind identifies a writable equipment slot within ChrAsmEquipment.

Phase 7b.0 — backend-only foundation for weapon/ammo slots (0–9, hash 7) and armor slots (12–15, hash 8). Phase 7c — extends the writer to talisman slots (17–21, hash 8). Talisman5 (index 21) accepts only the clear sentinel because vanilla Elden Ring caps the Talisman Pouch at 4 active slots; non-empty Talisman5 writes are rejected by the resolver at the apply layer (see app_templates_v2_apply.go). The unknown slots 10/11/16 and EquippedGreatRune remain out of scope.

const (
	EquipSlotLeftHandArmament1 EquipmentSlotKind = iota
	EquipSlotRightHandArmament1
	EquipSlotLeftHandArmament2
	EquipSlotRightHandArmament2
	EquipSlotLeftHandArmament3
	EquipSlotRightHandArmament3
	EquipSlotArrows1
	EquipSlotBolts1
	EquipSlotArrows2
	EquipSlotBolts2
	EquipSlotHead
	EquipSlotChest
	EquipSlotArms
	EquipSlotLegs
	EquipSlotTalisman1
	EquipSlotTalisman2
	EquipSlotTalisman3
	EquipSlotTalisman4
	EquipSlotTalisman5
)

type EquipmentWrite added in v1.0.0

type EquipmentWrite struct {
	Slot   EquipmentSlotKind
	Handle uint32
}

EquipmentWrite is one entry in a WriteEquipment batch. Handle == 0 clears the slot (writes 0xFFFFFFFF).

type EventFlagsBlock

type EventFlagsBlock struct {
	Flags      []byte // length = EventFlagsByteCount
	Terminator uint8
}

EventFlagsBlock — fixed-size 0x1BF99F bitfield + 1-byte terminator.

func (*EventFlagsBlock) Read

func (e *EventFlagsBlock) Read(r *Reader) error

func (*EventFlagsBlock) Write

func (e *EventFlagsBlock) Write(w *SectionWriter)

type FloatVector3

type FloatVector3 struct {
	X, Y, Z float32
}

FloatVector3 — 12 bytes (3×f32, little-endian).

func (*FloatVector3) Read

func (v *FloatVector3) Read(r *Reader) error

func (*FloatVector3) Write

func (v *FloatVector3) Write(w *SectionWriter)

type FloatVector4

type FloatVector4 struct {
	X, Y, Z, W float32
}

FloatVector4 — 16 bytes (4×f32). Used for quaternion-style angles in the save format despite the name; we keep the 4 components as-is.

func (*FloatVector4) Read

func (v *FloatVector4) Read(r *Reader) error

func (*FloatVector4) Write

func (v *FloatVector4) Write(w *SectionWriter)

type GaItem

type GaItem struct {
	Handle uint32
	ItemID uint32
}

type GaItemCapacity added in v1.5.0

type GaItemCapacity struct {
	PhysicalEmpty int
	CursorRoom    int
	Usable        int
}

GaItemCapacity describes the usable GaItem allocation capacity at one point in time. PhysicalEmpty is the number of empty records in the table, while CursorRoom is what the current allocator cursor can reach. Usable is always the smaller of those two values.

type GaItemDuplicateAnalysis added in v1.5.0

type GaItemDuplicateAnalysis struct {
	Handle      uint32
	Candidates  [2]GaItemDuplicateCandidate
	Repairable  bool
	RefusalCode string
	RefusalMsg  string
}

GaItemDuplicateAnalysis is the read-only preflight for one requested duplicate physical GaItem handle. Repairable is true only when every safety condition holds; the caller must still pick which physical index to keep. This analysis never mutates the slot and never picks a candidate on the user's behalf.

func AnalyzeGaItemDuplicate added in v1.5.0

func AnalyzeGaItemDuplicate(slot *SaveSlot, handle uint32) GaItemDuplicateAnalysis

AnalyzeGaItemDuplicate inspects one requested handle and decides whether a single, unambiguous physical duplicate pair can be safely deduplicated. It is fail-closed: any missing condition or ambiguity returns Repairable=false with a stable RefusalCode and never mutates slot. It deliberately makes no choice between the two candidates.

type GaItemDuplicateCandidate added in v1.5.0

type GaItemDuplicateCandidate struct {
	Index  int
	ItemID uint32
}

GaItemDuplicateCandidate is one physical GaItem record that shares a handle with another physical record. The two candidates always carry different ItemID values; which one to keep is a decision only the user can make.

type GaItemFull

type GaItemFull struct {
	Handle          uint32
	ItemID          uint32
	Unk2            int32  // weapon/armor: default -1 (0xFFFFFFFF)
	Unk3            int32  // weapon/armor: default -1 (0xFFFFFFFF)
	AoWGaItemHandle uint32 // weapon only: NoCustomAoWHandle when no AoW attached (see IsNoCustomAoWHandle for compat)
	Unk5            uint8  // weapon only: default 0
}

GaItemFull represents a complete GaItem entry with all variable-length fields. Size depends on item_id type: weapon=21B, armor=16B, everything else=8B. Matches Rust ER-Save-Editor save_slot.rs GaItem struct.

func (*GaItemFull) ByteSize

func (g *GaItemFull) ByteSize() int

ByteSize returns the serialized byte size of this entry.

func (*GaItemFull) IsEmpty

func (g *GaItemFull) IsEmpty() bool

IsEmpty returns true if this GaItem slot is unused.

func (*GaItemFull) Serialize

func (g *GaItemFull) Serialize(buf []byte) int

Serialize writes the GaItem entry into buf and returns bytes written. buf must have at least g.ByteSize() bytes available.

type GaItemRepackAnalysis added in v1.5.0

type GaItemRepackAnalysis struct {
	Before          GaItemCapacity
	ProjectedAfter  GaItemCapacity
	Recovered       int
	NonEmptyRecords int
}

GaItemRepackAnalysis is a read-only forecast of stable GaItem compaction. It deliberately makes no safety decision: validation/refusal belongs to the repack pre-flight. The caller can use this report to decide whether there is capacity to recover before any mutation is attempted.

func AnalyzeGaItemRepack added in v1.5.0

func AnalyzeGaItemRepack(slot *SaveSlot) GaItemRepackAnalysis

AnalyzeGaItemRepack calculates the capacity effect of the canonical stable compaction without modifying slot. Non-empty records preserve their physical order; after compaction scanGaItems would place the max-counter record at its compacted position, which determines the projected allocator cursor.

type GaItemRepackBlocker added in v1.5.0

type GaItemRepackBlocker struct {
	Code    string
	Message string
	Handle  uint32
	// contains filtered or unexported fields
}

GaItemRepackBlocker is one fail-closed reason why a GaItem repack cannot be attempted. Code is stable for callers; Message is concise and user-facing. Handle carries the offending GaItem handle for blockers that identify one structurally (currently only "duplicate_handle" for a physical GaItem duplicate); it is 0 for blockers that do not name a handle.

type GaItemRepackPlan added in v1.5.0

type GaItemRepackPlan struct {
	GaItems         []GaItemFull
	NonEmptyRecords int
	Changes         bool
}

GaItemRepackPlan is the deterministic in-memory layout produced by stable compaction. It is valid only for the exact, already preflighted slot from which it was built; the transaction layer is responsible for checking freshness before applying it to a candidate slot.

func BuildGaItemRepackPlan added in v1.5.0

func BuildGaItemRepackPlan(slot *SaveSlot) GaItemRepackPlan

BuildGaItemRepackPlan creates the canonical stable-compaction layout without modifying slot. It preserves every field and the relative order of non-empty records, then leaves a zero-value empty suffix of the original table length. Callers must run PreflightGaItemRepack before using the plan.

type GaItemRepackPreflight added in v1.5.0

type GaItemRepackPreflight struct {
	Analysis GaItemRepackAnalysis
	Blockers []GaItemRepackBlocker
}

GaItemRepackPreflight is the read-only result of checking whether a slot is safe to repack. Analysis is populated only when all refusal gates pass.

func PreflightGaItemRepack added in v1.5.0

func PreflightGaItemRepack(slot *SaveSlot) GaItemRepackPreflight

PreflightGaItemRepack validates only invariants required to preserve a slot during stable GaItem compaction. It never repairs or mutates slot. Structural and record-identity failures stop later phases; reference failures are aggregated in deterministic order for a useful refusal report.

type GaItemRepackResult added in v1.5.0

type GaItemRepackResult struct {
	Before    GaItemCapacity
	After     GaItemCapacity
	Recovered int
	Changed   bool
}

GaItemRepackResult describes a completed repack attempt. Repack never writes a save file; it only updates the passed in-memory candidate slot.

func RepackGaItems added in v1.5.0

func RepackGaItems(slot *SaveSlot) (GaItemRepackResult, error)

RepackGaItems applies the canonical stable compaction to slot as one transaction. A caller that must keep its live slot untouched should pass a CloneSlot candidate and publish that candidate only after success.

RepackGaItems refuses unsafe input before mutating it. Any error after the first mutation restores the complete SlotSnapshot, so the passed slot is left unchanged. It does not save a file or create a backup.

type GaitemGameData

type GaitemGameData struct {
	Count   int64
	Entries [gaitemEntryCount]GaitemGameDataEntry
}

GaitemGameData — header (i64 count) + 7000 entries × 16 bytes = 0x1B458 (112,008 bytes).

func (*GaitemGameData) Read

func (g *GaitemGameData) Read(r *Reader) error

func (*GaitemGameData) Write

func (g *GaitemGameData) Write(w *SectionWriter)

type GaitemGameDataEntry

type GaitemGameDataEntry struct {
	ID         uint32
	Unk0x4     uint8
	Pad0x5     [3]byte
	NextItemID uint32
	Unk0xc     uint8
	Pad0x0d    [3]byte
}

GaitemGameDataEntry — 16 bytes per entry. Layout: id u32 | unk0x4 u8 | pad0x5 [3]byte | next_item_id u32 | unk0xc u8 | pad0x0d [3]byte

func (*GaitemGameDataEntry) Read

func (e *GaitemGameDataEntry) Read(r *Reader) error

func (*GaitemGameDataEntry) Write

func (e *GaitemGameDataEntry) Write(w *SectionWriter)

type IdentityClass added in v1.1.0

type IdentityClass string

IdentityClass classifies how a record's identity is derived. It is orthogonal to Resolution — a record's class is known even when it fails DB resolution.

const (
	IdentityInstanceBacked       IdentityClass = "instance_backed"       // weapon/armor/aow with a per-instance GaItem
	IdentityHandleEncoded        IdentityClass = "handle_encoded"        // goods/talismans (itemID derived from handle)
	IdentityStackableAmmo        IdentityClass = "stackable_ammo"        // arrows/bolts — weapon prefix, but stackable
	IdentityTechnicalPlaceholder IdentityClass = "technical_placeholder" // Unarmed, naked armor
	IdentityUnknown              IdentityClass = "unknown"               // unresolved
)

type IntegrityError

type IntegrityError struct {
	Check   string `json:"check"`
	Message string `json:"message"`
}

IntegrityError describes a single post-mutation invariant violation.

func ValidatePostMutation

func ValidatePostMutation(slot *SaveSlot) []IntegrityError

ValidatePostMutation performs fast invariant checks after a slot mutation. Only checks crash-causing conditions — not full diagnostic scan. Returns nil if all checks pass, or a slice of violations.

Fail-closed: every duplicate acquisition Index across Inventory.CommonItems + KeyItems is reported. Callers run a fail-closed pre-flight (ScanDuplicateInventoryIndices) before mutating and refuse to proceed when the slot already holds duplicates, so any duplicate observed here was introduced by the mutation itself and must roll back.

func (IntegrityError) Error

func (e IntegrityError) Error() string

type InventoryIndexRepairChange added in v1.0.0

type InventoryIndexRepairChange struct {
	Scope    string `json:"scope"` // "inventory_common" | "inventory_key"
	Row      int    `json:"row"`
	Handle   uint32 `json:"handle"`
	OldIndex uint32 `json:"oldIndex"`
	NewIndex uint32 `json:"newIndex"`
}

InventoryIndexRepairChange describes one Index reassignment performed by RepairDuplicateInventoryIndices. NewIndex is guaranteed unique across the combined Inventory.CommonItems + KeyItems set after the repair completes.

func AssignFreshInventoryIndex added in v1.1.0

func AssignFreshInventoryIndex(slot *SaveSlot, scope string, row int) (InventoryIndexRepairChange, error)

AssignFreshInventoryIndex assigns a new, safe acquisition index to exactly one inventory record identified by scope + row. The new index is:

  • greater than all existing indices across inventory_common and inventory_key;
  • greater than InvEquipReservedMax, as a conservative floor for newly generated editor indices;
  • unique within the combined index space.

Both the in-memory InventoryItem and the raw slot.Data bytes are updated. NextAcquisitionSortId is advanced if the new index exceeds the current value. NextEquipIndex is never touched (see CE-108255-1).

Scope must be "inventory_common" or "inventory_key". This primitive is the building block for both duplicate-index repair and single-record index repair; the batch RepairDuplicateInventoryIndices uses its own counter loop for efficiency.

type InventoryIndexRepairReport added in v1.0.0

type InventoryIndexRepairReport struct {
	Changed int                          `json:"changed"`
	Changes []InventoryIndexRepairChange `json:"changes"`
}

InventoryIndexRepairReport is the outcome of one repair invocation. Changed == 0 means the slot was already clean (no-op, idempotent).

func RepairDuplicateInventoryIndices added in v1.0.0

func RepairDuplicateInventoryIndices(slot *SaveSlot) (InventoryIndexRepairReport, error)

RepairDuplicateInventoryIndices reassigns the Index of every duplicate occurrence in Inventory.CommonItems + KeyItems so that all non-empty entries share a globally-unique Index. The first occurrence of each value is kept; every subsequent occurrence gets a fresh Index > all existing values, taken from a counter seeded at max(NextAcquisitionSortId, max(existing Index)+1).

Updates both the in-memory InventoryItem and the matching uint32 in slot.Data so a subsequent WriteSave (or any direct raw read) sees the corrected Index. Also advances NextAcquisitionSortId / NextEquipIndex to stay > all assigned indices, with the matching slot.Data counters written back when their offsets are known.

Read scope is identical to ScanDuplicateInventoryIndices: empty / invalid handles are ignored, storage is not touched.

Idempotent: a second call on the repaired slot returns Changed=0.

type InventoryItem

type InventoryItem struct {
	GaItemHandle uint32
	Quantity     uint32
	Index        uint32
}

type IssueKey added in v1.1.0

type IssueKey struct {
	Slot   int    `json:"slot"`
	Domain string `json:"domain"`
	Code   string `json:"code"`
	Scope  string `json:"scope"`
	Row    int    `json:"row"`
	Handle uint32 `json:"handle"`
	Field  string `json:"field,omitempty"`
	Value  string `json:"value,omitempty"`
}

IssueKey uniquely and structurally identifies one repair issue. issueID = hex(SHA-256(canonical JSON of IssueKey)[:8]) — 16 hex chars. debugKey is the human-readable pipe-separated form for logs only; never parse it.

type ItemToAdd

type ItemToAdd struct {
	ItemID         uint32
	InvQty         int
	StorageQty     int
	ForceStackable bool
}

ItemToAdd describes a single item intended for batch addition. How it consumes slot resources (merge vs per-copy records, GaItem or not) is derived from the item ID via classifyItemAdd — callers must not pre-classify it. ForceStackable is the one explicit override: it marks weapon-prefixed ammo (arrows) that stack.

type MapID

type MapID [4]byte

MapID — 4 raw bytes encoding (area, block, x, y) in some order. The game treats them as opaque tuples; we store the bytes verbatim.

func (*MapID) Read

func (m *MapID) Read(r *Reader) error

func (*MapID) Write

func (m *MapID) Write(w *SectionWriter)
type MenuSaveLoad struct {
	Unk0x0 uint16
	Unk0x2 uint16
	Data   []byte // serialized length is len(Data); Size header is rewritten on Write
}

MenuSaveLoad — variable-size menu profile data block. Layout: unk0x0 u16 | unk0x2 u16 | size u32 | data[size] Reference: tmp/repos/er-save-manager/parser/world.py:MenuSaveLoad

func (m *MenuSaveLoad) ByteSize() int

ByteSize returns the serialized length: 8-byte header + payload.

func (m *MenuSaveLoad) Read(r *Reader) error
func (m *MenuSaveLoad) Write(w *SectionWriter)

type NetMan

type NetMan struct {
	Unk0x0 uint32
	Data   [0x20000]byte // 128 KB opaque payload
}

NetMan — network manager block, 131,076 bytes total (0x20004). Layout: unk0x0 u32 | data [0x20000]byte Reference: tmp/repos/er-save-manager/parser/world.py:NetMan

func (*NetMan) Read

func (n *NetMan) Read(r *Reader) error

func (*NetMan) Write

func (n *NetMan) Write(w *SectionWriter)

type NetworkParamValues

type NetworkParamValues struct {
	// --- Invader role ---
	MaxBreakInTargetListCount     int32   `json:"maxBreakInTargetListCount"`
	BreakInRequestIntervalTimeSec float32 `json:"breakInRequestIntervalTimeSec"`
	BreakInRequestTimeOutSec      float32 `json:"breakInRequestTimeOutSec"`
	BreakInRequestAreaCount       int32   `json:"breakInRequestAreaCount"`

	// --- Summon Host (host waiting for phantom) ---
	SummonTimeoutTime float32 `json:"summonTimeoutTime"`

	// --- Cooperator role (summon signs) ---
	ReloadSignIntervalTime2 float32 `json:"reloadSignIntervalTime2"`
	ReloadSignTotalCount    int32   `json:"reloadSignTotalCount"`
	ReloadSignCellCount     int32   `json:"reloadSignCellCount"`
	UpdateSignIntervalTime  float32 `json:"updateSignIntervalTime"`
	SingGetMax              int32   `json:"singGetMax"`
	SignDownloadSpan        float32 `json:"signDownloadSpan"`
	SignUpdateSpan          float32 `json:"signUpdateSpan"`

	// --- Blue role (Blue Cipher Ring) ---
	ReloadVisitListCoolTime   float32 `json:"reloadVisitListCoolTime"`
	MaxCoopBlueSummonCount    int32   `json:"maxCoopBlueSummonCount"`
	MaxVisitListCount         int32   `json:"maxVisitListCount"`
	ReloadSearchCoopBlueMin   float32 `json:"reloadSearchCoopBlueMin"`
	ReloadSearchCoopBlueMax   float32 `json:"reloadSearchCoopBlueMax"`
	AllAreaSearchRateCoopBlue int32   `json:"allAreaSearchRateCoopBlue"`
	AllAreaSearchRateVsBlue   int32   `json:"allAreaSearchRateVsBlue"`

	// --- Host role (Taunter's Tongue / visitor) ---
	VisitorListMax      int32   `json:"visitorListMax"`
	VisitorTimeOutTime  float32 `json:"visitorTimeOutTime"`
	VisitorDownloadSpan float32 `json:"visitorDownloadSpan"`
}

NetworkParamValues holds tunable PvP/multiplayer parameters grouped by player role.

func NetworkParamAggressiveBlue added in v1.0.0

func NetworkParamAggressiveBlue() NetworkParamValues

NetworkParamAggressiveBlue returns the "Aggressive Blue / Hunter" preset (Blue role). Fastest, widest co-op blue search for hunter-side testing with an active Blue Cipher Ring. MaxCoopBlueSummonCount (Blue Search Parallelism) and AllAreaSearchRateVsBlue (Retribution) stay vanilla — they are Experimental.

func NetworkParamAggressiveHost

func NetworkParamAggressiveHost() NetworkParamValues

NetworkParamAggressiveHost returns the "Aggressive Host" preset (Host role). Experimental.

func NetworkParamAggressiveHunter added in v1.0.0

func NetworkParamAggressiveHunter() NetworkParamValues

func NetworkParamAggressiveReds added in v1.0.0

func NetworkParamAggressiveReds() NetworkParamValues

NetworkParamAggressiveReds returns the "Aggressive Reds" preset (Invader role). Aggressive red invasion search: broad 12/12 area/target scan, 10s retry interval, and 7s timeout. TODO: add a separate Very Aggressive preset only after validating that tighter overlapping cycles do not starve matchmaking requests.

func NetworkParamAggressiveSummonGuest added in v1.0.0

func NetworkParamAggressiveSummonGuest() NetworkParamValues

func NetworkParamAggressiveSummonHost added in v1.0.0

func NetworkParamAggressiveSummonHost() NetworkParamValues

func NetworkParamAggressiveSummons added in v1.0.0

func NetworkParamAggressiveSummons() NetworkParamValues

NetworkParamAggressiveSummons returns the "Aggressive Summon Signs" preset (Cooperator role). Fastest sign refresh/upload and the largest internally consistent sign buffer (cellCount <= totalCount <= singGetMax: 32 <= 64 <= 96). Controls the summon-sign network path only; Summoning Pool activation is a separate World/Exploration feature. cellGroup* ranges are left untouched.

func NetworkParamDefaults

func NetworkParamDefaults() NetworkParamValues

NetworkParamDefaults returns the vanilla game defaults for all fields.

func NetworkParamFast

func NetworkParamFast() NetworkParamValues

NetworkParamFast returns the legacy "Fast Invasions" preset for backward compatibility.

func NetworkParamFastBlue

func NetworkParamFastBlue() NetworkParamValues

NetworkParamFastBlue returns the "Fast Blue" preset (Blue role). Experimental.

func NetworkParamFastInvasions

func NetworkParamFastInvasions() NetworkParamValues

NetworkParamFastInvasions returns the "Fast Invasions" preset (Invader role).

func NetworkParamFastSummons

func NetworkParamFastSummons() NetworkParamValues

NetworkParamFastSummons returns the "Fast Summons" preset (Cooperator role). Experimental.

func NetworkParamFasterBlue added in v1.0.0

func NetworkParamFasterBlue() NetworkParamValues

NetworkParamFasterBlue returns the "Faster Blue / Hunter" preset (Blue role). Faster and wider co-op blue search. MaxCoopBlueSummonCount stays at vanilla 2 (the server caps actual joins; raising it only inflates client-side search). AllAreaSearchRateVsBlue stays at vanilla 30 (retribution blue likely legacy in ER).

func NetworkParamFasterHunter added in v1.0.0

func NetworkParamFasterHunter() NetworkParamValues

func NetworkParamFasterReds added in v1.0.0

func NetworkParamFasterReds() NetworkParamValues

NetworkParamFasterReds returns the "Faster Reds" preset (Invader role). Faster red invasion search: wider 8/8 area/target scan, 12s retry interval, and 8s timeout. This keeps a small processing gap before the next cycle.

func NetworkParamFasterSummonGuest added in v1.0.0

func NetworkParamFasterSummonGuest() NetworkParamValues

func NetworkParamFasterSummonHost added in v1.0.0

func NetworkParamFasterSummonHost() NetworkParamValues

func NetworkParamFasterSummons added in v1.0.0

func NetworkParamFasterSummons() NetworkParamValues

NetworkParamFasterSummons returns the "Faster Summons & Pools" preset (Cooperator role). Faster sign download/upload and a larger, internally consistent sign buffer (cellCount <= totalCount <= singGetMax). Spatial cellGroup ranges are left at vanilla — they are an Experimental option, not part of this preset.

func NetworkParamLightInvasions added in v0.13.0

func NetworkParamLightInvasions() NetworkParamValues

NetworkParamLightInvasions returns the "Light / Safer" invasions preset. Moderate speed-up with a slightly lower detection surface than Fast Invasions.

func ReadNetworkParams

func ReadNetworkParams(ud11 []byte) (*NetworkParamValues, error)

ReadNetworkParams extracts current NetworkParam values from UserData11.

type PS5Activity

type PS5Activity struct {
	Data [0x20]byte
}

PS5Activity — 32 opaque bytes.

func (*PS5Activity) Read

func (p *PS5Activity) Read(r *Reader) error

func (*PS5Activity) Write

func (p *PS5Activity) Write(sw *SectionWriter)

type Platform

type Platform string
const (
	PlatformPC Platform = "PC"
	PlatformPS Platform = "PS4"
)

func ClassifyContainer added in v1.3.0

func ClassifyContainer(data []byte) Platform

ClassifyContainer returns the platform of an unambiguous native save container, or "" when the container is ambiguous/unsupported. Detection is purely by leading container magic — never by decryption, and never by filename. An AES-encrypted PC save (no raw BND4 prefix) is intentionally classified as unsupported.

type PlayerCoordinates

type PlayerCoordinates struct {
	Coordinates  FloatVector3
	MapID        MapID
	Angle        FloatVector4
	GameMan0xbf0 uint8
	UnkCoords    FloatVector3
	UnkAngle     FloatVector4
}

PlayerCoordinates — current player position block, 61 bytes.

Layout: coords (12) | map_id (4) | angle (16) | game_man_0xbf0 (1) |

unk_coords (12) | unk_angle (16)

(er-save-manager labels this 57 bytes in a comment — the actual struct

is 61 bytes; the comment is stale.)

Reference: tmp/repos/er-save-manager/parser/world.py:PlayerCoordinates

func (*PlayerCoordinates) Read

func (p *PlayerCoordinates) Read(r *Reader) error

func (*PlayerCoordinates) Write

func (p *PlayerCoordinates) Write(w *SectionWriter)

type PlayerGameData

type PlayerGameData struct {
	Level               uint32
	Vigor               uint32
	Mind                uint32
	Endurance           uint32
	Strength            uint32
	Dexterity           uint32
	Intelligence        uint32
	Faith               uint32
	Arcane              uint32
	Souls               uint32
	SoulMemory          uint32
	CharacterName       [16]uint16
	Gender              uint8
	VoiceType           uint8 // 0=Young1, 1=Young2, 2=Mature1, 3=Mature2, 4=Aged1, 5=Aged2
	Class               uint8
	TalismanSlots       uint8  // additional talisman slots (0-3), total = 1 + this value
	ClearCount          uint32 // NG+ cycle (0=NG, 1=NG+1, ..., 7=NG+7)
	GreatRuneOn         uint8  // Great Rune buff active (0=off, 1=on)
	EquippedGreatRune   uint32 // equipped Great Rune item ID (0=none)
	ScadutreeBlessing   uint8
	ShadowRealmBlessing uint8
}

type PlayerGameDataHash

type PlayerGameDataHash struct {
	Level                      uint32
	Stats                      uint32
	Archetype                  uint32
	PlayerGameData0xc0         uint32
	Padding                    uint32
	Runes                      uint32
	RunesMemory                uint32
	EquippedWeapons            uint32
	EquippedArmorsAndTalismans uint32
	EquippedItems              uint32
	EquippedSpells             uint32
	Rest                       [0x54]byte
}

PlayerGameDataHash — 128 bytes (0x80), 11×u32 + 0x54 raw bytes.

Reference: tmp/repos/er-save-manager/parser/world.py:PlayerGameDataHash

Spec/22 describes the length as `slot_end - position`, but in practice every save we have observed places this 128-byte block at SlotSize - 0x80 and any remaining tail bytes are zero padding (captured separately as SaveSlot.RestPadding by the rebuild parser).

func (*PlayerGameDataHash) Read

func (h *PlayerGameDataHash) Read(r *Reader) error

func (*PlayerGameDataHash) Write

func (h *PlayerGameDataHash) Write(w *SectionWriter)

type PreEventFlagsScalars

type PreEventFlagsScalars struct {
	GameMan0x8c          uint8
	GameMan0x8d          uint8
	GameMan0x8e          uint8
	TotalDeathsCount     uint32
	CharacterType        int32
	InOnlineSessionFlag  uint8
	CharacterTypeOnline  uint32
	LastRestedGrace      uint32
	NotAloneFlag         uint8
	InGameCountdownTimer uint32
	UnkGameDataMan0x124  uint32
}

PreEventFlagsScalars — block of scalar fields between TutorialData and the event_flags bitfield. Layout (all little-endian):

gameman_0x8c             u8
gameman_0x8d             u8
gameman_0x8e             u8
total_deaths_count       u32
character_type           i32
in_online_session_flag   u8
character_type_online    u32
last_rested_grace        u32
not_alone_flag           u8
in_game_countdown_timer  u32
unk_gamedataman_0x124    u32

Total: 3 + 4 + 4 + 1 + 4 + 4 + 1 + 4 + 4 = 29 bytes. Reference: tmp/repos/er-save-manager/parser/user_data_x.py:358-371

func (*PreEventFlagsScalars) Read

func (s *PreEventFlagsScalars) Read(r *Reader) error

func (*PreEventFlagsScalars) Write

func (s *PreEventFlagsScalars) Write(w *SectionWriter)

type ProfileSummary

type ProfileSummary struct {
	CharacterName [16]uint16
	Level         uint32
}

func (*ProfileSummary) Read

func (p *ProfileSummary) Read(r *Reader) error

func (*ProfileSummary) Serialize

func (p *ProfileSummary) Serialize(data []byte, offset int)

type QuantityClampChange added in v1.1.0

type QuantityClampChange struct {
	Scope       string `json:"scope"`
	Row         int    `json:"row"`
	Handle      uint32 `json:"handle"`
	OldQuantity uint32 `json:"oldQuantity"`
	NewQuantity uint32 `json:"newQuantity"`
	Cap         uint64 `json:"cap"`
}

QuantityClampChange describes one ClampInventoryQuantityAt result. OldQuantity and NewQuantity are the RAW quantity fields (high bit preserved), so a caller can tell exactly what changed on the wire.

func ClampInventoryQuantityAt added in v1.1.0

func ClampInventoryQuantityAt(slot *SaveSlot, scope string, row int, fingerprint string) (QuantityClampChange, error)

ClampInventoryQuantityAt clamps the quantity of the single record at scope+row down to its authoritative effective cap, recomputed from the item DB at apply time. It never accepts a caller-supplied cap.

The record is re-resolved, its fingerprint stale-checked against the scan-time value, and the raw/in-memory state verified for consistency before any write. Records without an applicable cap, with a zero cap (item not permitted in the container — removal, not clamping, is the correct repair), or already at/below the cap are rejected with no mutation. The high quantity bit (0x80000000) is preserved.

Every fallible check runs before the single write, so no failure is possible afterwards and the primitive needs no snapshot of its own (the App repair wrapper still snapshots for defense in depth). Exactly one 4-byte raw quantity field and its in-memory counterpart change; handle, acquisition index, headers, GaItems and every other record are left untouched.

scope is one of the repairScope* constants; row is the index into the matching in-memory list exactly as the scanner produced it.

type Reader

type Reader struct {
	// contains filtered or unexported fields
}

func NewReader

func NewReader(data []byte) *Reader

func (*Reader) FindPattern

func (r *Reader) FindPattern(pattern []byte) int

FindPattern searches for a byte pattern in the data starting from the current position.

func (*Reader) Len

func (r *Reader) Len() int

func (*Reader) Pos

func (r *Reader) Pos() int

func (*Reader) ReadAt

func (r *Reader) ReadAt(p []byte, off int64) (n int, err error)

ReadAt reads data into p starting at off.

func (*Reader) ReadBytes

func (r *Reader) ReadBytes(n int) ([]byte, error)

func (*Reader) ReadF32

func (r *Reader) ReadF32() (float32, error)

func (*Reader) ReadI32

func (r *Reader) ReadI32() (int32, error)

func (*Reader) ReadSizedBytes

func (r *Reader) ReadSizedBytes(maxSize int, fieldName string) ([]byte, error)

ReadSizedBytes mirrors WriteSizedBytes: reads an i32 size, then `size` raw bytes. Returns the data slice (referencing the underlying buffer — copy if you need to retain it past the next read).

Sanity-checks `size` against the provided maximum (matches er-save-manager behaviour: write size as 0 if unreasonable; here we surface as an error).

func (*Reader) ReadU8

func (r *Reader) ReadU8() (uint8, error)

func (*Reader) ReadU16

func (r *Reader) ReadU16() (uint16, error)

func (*Reader) ReadU32

func (r *Reader) ReadU32() (uint32, error)

func (*Reader) ReadU64

func (r *Reader) ReadU64() (uint64, error)

func (*Reader) Seek

func (r *Reader) Seek(offset int64, whence int) (int64, error)

type RehandleChange added in v1.1.0

type RehandleChange struct {
	Scope     string `json:"scope"`
	Row       int    `json:"row"`
	OldHandle uint32 `json:"oldHandle"`
	NewHandle uint32 `json:"newHandle"`
	ItemID    uint32 `json:"itemID"`
}

RehandleChange describes the outcome of one RehandleInventoryRecord call.

func RehandleInventoryRecord added in v1.1.0

func RehandleInventoryRecord(slot *SaveSlot, scope string, row int) (RehandleChange, error)

RehandleInventoryRecord assigns a fresh unique handle to the record at scope+row, leaving the record in its container. ItemID, quantity, and acquisition index are preserved.

Weapons (0x80), armor (0x90), and AoW (0xC0): a new GaItem entry is allocated. If GaItems is full, the function returns an error with no partial mutation — slot.Data and the in-memory list are unchanged. Talismans (0xA0) and goods (0xB0): no GaItem is allocated; only GaMap and the binary handle field are updated.

GaMap gains an entry for the new handle. The old handle's GaMap entry is left in place — other records may still reference it.

Scope: "inventory_common" | "inventory_key" | "storage_common".

type RepairIssue added in v1.1.0

type RepairIssue struct {
	IssueID       string   `json:"issueID"`
	DebugKey      string   `json:"debugKey"`
	Fingerprint   string   `json:"fingerprint"`
	Key           IssueKey `json:"key"`
	Description   string   `json:"description"`
	Severity      string   `json:"severity"`
	Actions       []string `json:"actions"`
	DefaultAction string   `json:"defaultAction"`
}

RepairIssue is one problem found by ScanRepairIssues. It is read-only — no slot mutation occurs during scanning.

func ScanRepairIssues added in v1.1.0

func ScanRepairIssues(slotIndex int, slot *SaveSlot) []RepairIssue

ScanRepairIssues returns all repair issues found in slot. Read-only. It resolves the physical record collection once and delegates to ScanRepairIssuesFromRecords so coverage and scanning share identical record semantics.

func ScanRepairIssuesFromRecords added in v1.1.0

func ScanRepairIssuesFromRecords(slotIndex int, slot *SaveSlot, records []ResolvedRecord) []RepairIssue

ScanRepairIssuesFromRecords scans a pre-resolved record collection. Callers that also build a coverage report should resolve once (ResolveInventoryRecords) and pass the same slice to both this function and BuildCoverageReport to guarantee the two never diverge. Read-only.

type Resolution added in v1.1.0

type Resolution string

Resolution is the terminal resolution status of a physical record. The three values form an exhaustive partition of every non-empty record:

TotalPhysical = KnownDB + TechnicalPlaceholder + Unknown

"Resolved" in the coverage report means KnownDB + TechnicalPlaceholder. A record being Resolved says nothing about whether it is *valid* — that is the scanner's job (structural / category checks), reported separately as issues.

const (
	ResolutionKnownDB              Resolution = "known_db"
	ResolutionTechnicalPlaceholder Resolution = "technical_placeholder"
	ResolutionUnknown              Resolution = "unknown"
)

type ResolvedRecord added in v1.1.0

type ResolvedRecord struct {
	Scope                 string        // repairScope* — inventory_common / inventory_key / storage_common
	Row                   int           // compacted/action row within the scope — index into slot.*.CommonItems, addressed by repair primitives
	PhysicalRow           int           // physical slot index in the raw binary array; differs from Row when storage has gaps (== Row for inventory / when raw data is unavailable)
	IndexDedup            bool          // participates in duplicate_acquisition_index dedup (inventory, not storage)
	Handle                uint32        // raw GaItemHandle as stored
	HandleType            uint32        // handle & GaHandleTypeMask
	ItemID                uint32        // raw itemID (GaMap, else db.HandleToItemID) — never rewritten
	DisplayID             uint32        // normalized display itemID (Wondrous Physick raw→display alias)
	BaseID                uint32        // DB base itemID (upgrade/infusion stripped) when resolved
	Name                  string        // DB name when resolved; placeholder name for naked armor
	Category              string        // DB category when resolved
	MaxInventory          uint32        // conservative Normal Mode DB cap; meaningful only when Resolution == KnownDB
	MaxStorage            uint32        // conservative Normal Mode DB cap; meaningful only when Resolution == KnownDB
	GameMaxInventory      uint32        // technical game cap; meaningful only when GameMaxInventoryKnown
	GameMaxStorage        uint32        // technical game cap; meaningful only when GameMaxStorageKnown
	GameMaxInventoryKnown bool          // distinguishes a known zero (prohibited) from missing limit data
	GameMaxStorageKnown   bool          // distinguishes a known zero (prohibited) from missing limit data
	ScalesWithNG          bool          // conservative Normal Mode flag; never used as save-integrity truth
	Quantity              uint32        // record quantity as stored
	AcquisitionIndex      uint32        // record acquisition/sort index as stored
	HasGaItem             bool          // a per-instance GaItem actually exists for this handle. slot.GaMap is built 1:1 from the non-empty slot.GaItems entries (structures.go scanGaItems), so GaMap membership is equivalent to GaItem existence — this is not a mere numeric coincidence.
	Identity              IdentityClass // instance-backed / handle-encoded / stackable ammo / placeholder / unknown
	Resolution            Resolution    // KnownDB / TechnicalPlaceholder / Unknown
	UnknownReason         UnknownReason // set only when Resolution == Unknown
	Fingerprint           string        // record-state fingerprint (handle+qty+index) for stale-checks
}

ResolvedRecord is the canonical, read-only view of one physical inventory or storage record, shared by the core scanner, the coverage report, and the editor workspace. It is produced once per record by the resolver so all three consumers see identical semantics.

func ResolveInventoryRecords added in v1.1.0

func ResolveInventoryRecords(slot *SaveSlot) []ResolvedRecord

ResolveInventoryRecords resolves every non-empty physical record across the inventory-common, key-item, and storage-common sections exactly once, in a stable order. Empty/invalid handles are skipped. This is the single source of record semantics for both BuildCoverageReport and the scanner.

func ResolveRecord added in v1.1.0

func ResolveRecord(slot *SaveSlot, scope string, row int, handle, qty, acq uint32) ResolvedRecord

ResolveRecord resolves a single physical record into its canonical form. It never mutates the slot and never rewrites the raw itemID; DisplayID carries the normalized ID used for DB lookups (e.g. Wondrous Physick).

type RideGameData

type RideGameData struct {
	Coordinates FloatVector3
	MapID       MapID
	Angle       FloatVector4
	HP          int32
	State       uint32 // HorseState enum (3=DEAD, 13=ACTIVE)
}

RideGameData — Torrent / horse state. 0x28 = 40 bytes. Layout: coordinates(12) | map_id(4) | angle(16) | hp(i32) | state(u32) Reference: tmp/repos/er-save-manager/parser/world.py:RideGameData

func (*RideGameData) Read

func (h *RideGameData) Read(r *Reader) error

func (*RideGameData) Write

func (h *RideGameData) Write(w *SectionWriter)

type SaveFile

type SaveFile struct {
	Platform         Platform
	Encrypted        bool
	IV               []byte
	Header           []byte
	Slots            [10]SaveSlot
	SteamID          uint64
	UserData10       CSMenuSystemSaveLoad
	ActiveSlots      [10]bool
	ProfileSummaries [10]ProfileSummary
	UserData11       []byte
}

func LoadSave

func LoadSave(path string) (*SaveFile, error)

func (*SaveFile) CleanResidualSlots added in v1.0.0

func (s *SaveFile) CleanResidualSlots() int

CleanResidualSlots zeroes every slot flagged inactive that still carries residual character data (see SlotHasResidualData). Returns the number of slots cleaned. Active slots are never touched. Idempotent: a second call returns 0.

func (*SaveFile) ClearSlot added in v1.0.0

func (s *SaveFile) ClearSlot(idx int)

ClearSlot zeroes slot idx entirely IN PLACE — data block, active flag, and the full ProfileSummary region. This mirrors the game's positional deletion model: slots 0-9 keep their positions and only the target slot is cleared; subsequent slots are NOT shifted down. (Confirmed by the independent per-slot active-flag array at 0x1954 — gaps between active slots are valid — and by the reference ER-Save-Editor, which is purely positional.)

func (*SaveFile) SaveFile

func (s *SaveFile) SaveFile(path string) error

func (*SaveFile) SlotHasResidualData added in v1.0.0

func (s *SaveFile) SlotHasResidualData(idx int) bool

SlotHasResidualData reports whether slot idx carries leftover character data (a slot-data name or a ProfileSummary name) while its active flag is cleared. This is the "phantom" state produced when a character is deleted in-game (the game clears the active flag but does not zero the data block / summary): the game ignores the slot (flag=0), but a name-based UI would show it as a duplicate. Active slots are never residual.

type SaveSlot

type SaveSlot struct {
	Data      []byte
	Version   uint32 // slot format version (offset 0x00); 0 = empty slot
	Player    PlayerGameData
	GaMap     map[uint32]uint32
	GaItems   []GaItemFull // parsed GaItem array (5118 or 5120 entries)
	Inventory EquipInventoryData
	Storage   EquipInventoryData
	SteamID   uint64
	Warnings  []string // non-fatal issues detected during parsing

	MagicOffset      int
	InventoryEnd     int
	EventFlagsOffset int

	// Dynamic offsets from Python logic
	PlayerDataOffset      int
	FaceDataOffset        int
	StorageBoxOffset      int
	IngameTimerOffset     int
	GaItemDataOffset      int      // start of GaItemData section (distinct_acquired_items_count header)
	TutorialDataOffset    int      // start of TutorialData block (header at offset, per er-save-manager world.py)
	ClearCountOffset      int      // NG+ cycle counter (uint32) — after BloodStain in dynamic chain
	EquipItemsIDOffset    int      // start of EquippedItemsItemIds section
	EquippedSpellsOffset  int      // start of EquippedSpells section (14×8 + 4 active_index = 0x74)
	UnlockedRegionsOffset int      // start of unlocked_regions struct (count u32 + count*4 IDs)
	UnlockedRegions       []uint32 // parsed unlocked region IDs (drives invasion / blue-summon eligibility)

	// SectionMap holds the section boundaries used by RebuildSlot. Populated
	// after parsing finishes (or in a degraded form for empty / unparseable slots).
	// Sections cover [0, SlotSize) contiguously with no gaps or overlaps.
	SectionMap []SectionRange

	// Tracked indices for type-segregated GaItem placement.
	// The game expects AoW entries at low indices, then armor, then weapons.
	// Matching Rust ER-Save-Editor's next_aow_index / next_armament_or_armor_index.
	NextAoWIndex      int    // next free index for AoW entries (after last AoW + 1)
	NextArmamentIndex int    // next free index for weapon/armor entries (after highest-counter entry + 1)
	NextGaItemHandle  uint32 // global handle counter (lower 16 bits), next value to assign
	PartGaItemHandle  uint8  // part_id (bits 16-23 of handle), extracted from first entry
}

func CloneSlot added in v1.5.0

func CloneSlot(slot *SaveSlot) *SaveSlot

CloneSlot returns a deep, independent copy of slot. It is intended for candidate-only mutations: callers can run a transaction on the clone and publish it only after all validation succeeds.

func (*SaveSlot) CalculateDynamicOffsets added in v1.0.0

func (s *SaveSlot) CalculateDynamicOffsets() error

CalculateDynamicOffsets is the exported wrapper over the package-internal calculateDynamicOffsets. Phase 7d.3 introduces it so the apply-spells test fixtures (in package main) can materialise a calibrated SaveSlot from a hand-built buffer without running the full Read pipeline. The production code paths still call the unexported form via parseFromData; this wrapper exists solely as a test seam.

func (*SaveSlot) FaceDataStart

func (s *SaveSlot) FaceDataStart() int

FaceDataStart returns the byte offset in slot.Data where the FaceData blob begins. FaceData is FaceDataBlobSize (303) bytes ending at FaceDataOffset.

func (*SaveSlot) Read

func (s *SaveSlot) Read(r *Reader, platform string) error

func (*SaveSlot) SyncPlayerToData

func (s *SaveSlot) SyncPlayerToData()

func (*SaveSlot) Write

func (s *SaveSlot) Write(platform string) []byte

func (*SaveSlot) WriteEquipment added in v1.0.0

func (s *SaveSlot) WriteEquipment(writes []EquipmentWrite) error

WriteEquipment applies a batch of equipment slot writes atomically.

Semantics:

  • All writes are validated before any byte is mutated. If any write fails validation, the slot data and hash bytes remain unchanged.
  • Handle == 0 clears the slot to 0xFFFFFFFF.
  • Non-zero handles must exist in slot.GaMap and match the slot's class (weapon / ammo / armor). The 0xC0 AoW handles are rejected for weapon slots in Phase 7b.0 — equipping an Ash of War as a weapon is out of scope, even though the read-side encoding rule would technically accept it.
  • After a successful write, hash 7 is recomputed if any slot 0–9 was touched, and hash 8 is recomputed if any slot 12–15 was touched. Other hash entries are not modified.

Concurrency: callers that share a SaveSlot across goroutines must hold the slot-level lock for the entire WriteEquipment call.

func (*SaveSlot) WriteSpells added in v1.0.0

func (s *SaveSlot) WriteSpells(writes []SpellWrite) error

WriteSpells is the batch equivalent of PatchEquippedSpell with one extra responsibility: it recomputes hash entry [10] (the EquippedSpells hash) so the in-save hash block stays consistent with the new spell loadout. Mirrors the WriteEquipment pattern (hash[7] / hash[8] inline recompute) and is deliberately the ONLY production-code path that touches hash[10].

Atomicity: every write is structurally validated (slot index range + duplicate detection) BEFORE any byte is mutated. Any validation failure returns the error without touching slot.Data, matching WriteEquipment's no-partial-write invariant. Per-write semantic validation (offset bounds, nil slot, etc.) is delegated to PatchEquippedSpell, which itself never mutates on failure.

Hash discipline: only hash[10] is touched. hash[7] (weapons), hash[8] (armor/talismans) and every other hash entry are left untouched — this writer never invalidates work done by WriteEquipment or by any future per-section writer.

Concurrency: callers that share a SaveSlot across goroutines must hold the slot-level lock for the entire WriteSpells call.

type SectionRange

type SectionRange struct {
	Name  string
	Start int
	End   int
}

SectionRange describes a contiguous byte range inside slot.Data. End is exclusive. Size returns End - Start.

func (SectionRange) Size

func (r SectionRange) Size() int

Size returns the byte length of the section.

type SectionWriter

type SectionWriter struct {
	// contains filtered or unexported fields
}

SectionWriter is a small append-only writer used by section serializers when rebuilding a slot. It mirrors the primitive helpers on Reader and keeps an explicit cursor so callers can track the resulting offset.

Unlike io.Writer it cannot fail — writes always succeed against an internal []byte buffer that grows on demand.

func NewSectionWriter

func NewSectionWriter(hint int) *SectionWriter

NewSectionWriter returns a writer pre-allocated to the given hint capacity. hint may be 0; the buffer will grow as needed.

func (*SectionWriter) Bytes

func (w *SectionWriter) Bytes() []byte

Bytes returns the underlying buffer (no copy).

func (*SectionWriter) Len

func (w *SectionWriter) Len() int

Len returns the current write cursor (== len(Bytes())).

func (*SectionWriter) PadZeros

func (w *SectionWriter) PadZeros(n int)

PadZeros appends n zero bytes. n must be >= 0.

func (*SectionWriter) WriteBytes

func (w *SectionWriter) WriteBytes(b []byte)

WriteBytes appends raw bytes verbatim.

func (*SectionWriter) WriteF32

func (w *SectionWriter) WriteF32(v float32)

func (*SectionWriter) WriteI32

func (w *SectionWriter) WriteI32(v int32)

func (*SectionWriter) WriteSizedBytes

func (w *SectionWriter) WriteSizedBytes(data []byte)

WriteSizedBytes serializes a size-prefixed blob: i32 size followed by data. Used for `field_area`, `world_area`, `world_geom_man`, `world_geom_man2`, `rend_man` (see tmp/repos/er-save-manager/parser/slot_rebuild.py).

func (*SectionWriter) WriteU8

func (w *SectionWriter) WriteU8(v uint8)

func (*SectionWriter) WriteU16

func (w *SectionWriter) WriteU16(v uint16)

func (*SectionWriter) WriteU32

func (w *SectionWriter) WriteU32(v uint32)

func (*SectionWriter) WriteU64

func (w *SectionWriter) WriteU64(v uint64)

type SizePrefixedBlob

type SizePrefixedBlob struct {
	Size int32  // raw header value
	Data []byte // length matches Size when Size is in valid range
}

SizePrefixedBlob represents a `(size: i32, data: bytes[size])` section. The raw `Size` header is preserved verbatim even when out of range (matching er-save-manager behaviour); in that case `Data` is empty and the section serializes as just the 4-byte header.

func (*SizePrefixedBlob) ByteSize

func (b *SizePrefixedBlob) ByteSize() int

ByteSize returns the serialized length: 4-byte header + payload bytes.

func (*SizePrefixedBlob) Read

func (b *SizePrefixedBlob) Read(r *Reader, maxSize int32, fieldName string) error

func (*SizePrefixedBlob) Write

func (b *SizePrefixedBlob) Write(w *SectionWriter)

type SlotAccessor

type SlotAccessor struct {
	Data     []byte
	Warnings []string
}

SlotAccessor provides bounds-checked read/write access to a save slot's raw byte buffer. It collects non-fatal warnings (e.g. clamped dynamic sizes) separately from fatal errors.

func NewSlotAccessor

func NewSlotAccessor(data []byte) *SlotAccessor

func (*SlotAccessor) CheckBounds

func (sa *SlotAccessor) CheckBounds(off, size int, label string) error

CheckBounds validates that a write of `size` bytes at `off` is safe.

func (*SlotAccessor) ReadDynamicSize

func (sa *SlotAccessor) ReadDynamicSize(off int, maxSize int, name string) (int, error)

ReadDynamicSize reads a uint32 size value from untrusted save data and clamps it to a sane maximum. Returns 0 (not error) when clamped, but appends a warning. This is the correct behavior for PS4 saves which often have garbage in size fields.

func (*SlotAccessor) ReadU8

func (sa *SlotAccessor) ReadU8(off int) (uint8, error)

ReadU8 reads a single byte at the given offset with bounds checking.

func (*SlotAccessor) ReadU16

func (sa *SlotAccessor) ReadU16(off int) (uint16, error)

ReadU16 reads a little-endian uint16 at the given offset with bounds checking.

func (*SlotAccessor) ReadU32

func (sa *SlotAccessor) ReadU32(off int) (uint32, error)

ReadU32 reads a little-endian uint32 at the given offset with bounds checking.

func (*SlotAccessor) ReadU64

func (sa *SlotAccessor) ReadU64(off int) (uint64, error)

ReadU64 reads a little-endian uint64 at the given offset with bounds checking.

func (*SlotAccessor) WriteU8

func (sa *SlotAccessor) WriteU8(off int, val uint8) error

WriteU8 writes a single byte at the given offset with bounds checking.

func (*SlotAccessor) WriteU16

func (sa *SlotAccessor) WriteU16(off int, val uint16) error

WriteU16 writes a little-endian uint16 at the given offset with bounds checking.

func (*SlotAccessor) WriteU32

func (sa *SlotAccessor) WriteU32(off int, val uint32) error

WriteU32 writes a little-endian uint32 at the given offset with bounds checking.

func (*SlotAccessor) WriteU64

func (sa *SlotAccessor) WriteU64(off int, val uint64) error

WriteU64 writes a little-endian uint64 at the given offset with bounds checking.

type SlotDiagnostics

type SlotDiagnostics struct {
	SlotIndex int               `json:"slotIndex"`
	Issues    []DiagnosticIssue `json:"issues"`
}

SlotDiagnostics holds the results of a comprehensive slot corruption scan.

func DiagnoseSaveCorruption

func DiagnoseSaveCorruption(slot *SaveSlot, slotIndex int) SlotDiagnostics

DiagnoseSaveCorruption performs a comprehensive corruption scan on a slot. Returns all found issues sorted by severity.

type SlotSnapshot

type SlotSnapshot struct {
	Data                  []byte
	Version               uint32
	Player                PlayerGameData
	GaMap                 map[uint32]uint32
	GaItems               []GaItemFull
	Inventory             EquipInventoryData
	Storage               EquipInventoryData
	SteamID               uint64
	Warnings              []string
	MagicOffset           int
	InventoryEnd          int
	EventFlagsOffset      int
	PlayerDataOffset      int
	FaceDataOffset        int
	StorageBoxOffset      int
	IngameTimerOffset     int
	GaItemDataOffset      int
	TutorialDataOffset    int
	ClearCountOffset      int
	EquipItemsIDOffset    int
	EquippedSpellsOffset  int
	UnlockedRegionsOffset int
	UnlockedRegions       []uint32
	SectionMap            []SectionRange
	NextAoWIndex          int
	NextArmamentIndex     int
	NextGaItemHandle      uint32
	PartGaItemHandle      uint8
}

SlotSnapshot holds a deep copy of all mutable SaveSlot state for rollback.

func SnapshotSlot

func SnapshotSlot(slot *SaveSlot) SlotSnapshot

SnapshotSlot creates a deep copy of all mutable slot state.

type SlotUsage

type SlotUsage struct {
	GaItemsUsed    int
	GaItemsMax     int
	GaItemDataUsed int
	GaItemDataMax  int
	InventoryUsed  int
	InventoryMax   int
	StorageUsed    int
	StorageMax     int
}

SlotUsage holds the count of used vs max slots for each container.

func CountSlotUsage

func CountSlotUsage(slot *SaveSlot) SlotUsage

CountSlotUsage counts used entries in all slot containers by scanning binary data.

type SpawnPointBlock

type SpawnPointBlock struct {
	PadAfterCoords         [2]byte
	SpawnPointEntityID     uint32
	GameMan0xb64           uint32
	HasTempSpawnPoint      bool   // version >= 65
	TempSpawnPointEntityID uint32 // populated only if HasTempSpawnPoint
	HasGameMan0xcb3        bool   // version >= 66
	GameMan0xcb3           uint8  // populated only if HasGameMan0xcb3
}

SpawnPointBlock — fields after PlayerCoordinates and its 2-byte padding.

Layout:

pad_after_coords           [2]byte (always zero, preserved verbatim)
spawn_point_entity_id      u32
game_man_0xb64             u32
temp_spawn_point_entity_id u32   (only when slot Version >= 65)
game_man_0xcb3             u8    (only when slot Version >= 65 — see note)

Note: er-save-manager gates the two trailing fields on version >= 65 and >= 66 separately. Our saves are always version >= 230, so both fire; we pass the slot version explicitly so the rebuild matches whatever the slot was originally read with.

Reference: tmp/repos/er-save-manager/parser/user_data_x.py:386-395

func (*SpawnPointBlock) ByteSize

func (s *SpawnPointBlock) ByteSize() int

ByteSize reflects the version-gated layout chosen at Read time.

func (*SpawnPointBlock) Read

func (s *SpawnPointBlock) Read(r *Reader, version uint32) error

func (*SpawnPointBlock) Write

func (s *SpawnPointBlock) Write(w *SectionWriter)

type SpellWrite added in v1.0.0

type SpellWrite struct {
	SlotIndex int
	SpellID   uint32
}

SpellWrite is a single equipped-spell mutation request. SlotIndex is 0..13 (matches the save's spell slot ordering); SpellID is a raw MagicParam ID (e.g. 0x1770 for Catch Flame) or EquippedSpellEmptySentinel (0xFFFFFFFF) to clear the slot.

Templates v2 stores spells with full DB-style item IDs (0x40XXXXXX); the apply layer is responsible for stripping the prefix via db.ItemIDToMagicParamID before constructing SpellWrite entries.

type TrailingFixedBlock

type TrailingFixedBlock struct {
	Weather     WorldAreaWeather
	Time        WorldAreaTime
	BaseVersion BaseVersion
	SteamID     uint64
	PS5Activity PS5Activity
	DLC         DLCSection
}

TrailingFixedBlock — Weather + Time + BaseVersion + SteamID + PS5Activity + DLC. Total 12 + 12 + 16 + 8 + 32 + 50 = 130 bytes.

func (*TrailingFixedBlock) Read

func (b *TrailingFixedBlock) Read(r *Reader) error

func (*TrailingFixedBlock) Write

func (b *TrailingFixedBlock) Write(sw *SectionWriter)

type TransferDirection added in v1.0.0

type TransferDirection int

TransferDirection identifies the source→destination direction of a move between Inventory.CommonItems and Storage.CommonItems.

const (
	TransferToStorage   TransferDirection = iota // Inventory.CommonItems → Storage.CommonItems
	TransferToInventory                          // Storage.CommonItems → Inventory.CommonItems
)

type TransferOptions added in v1.0.0

type TransferOptions struct {
	DestCaps map[uint32]uint32
}

TransferOptions carries optional parameters for MoveItemsBetweenContainers. DestCaps provides per-handle quantity caps for the destination container (e.g. MaxStorage for to-storage direction). When a stackable handle is missing from DestCaps or maps to 0, the move is rejected with SkipReasonMissingCap rather than silently exceeding the in-game cap. Non-stackable items ignore the cap (qty=1 per record).

type TransferResult added in v1.0.0

type TransferResult struct {
	Moved   int            `json:"moved"`
	Skipped []TransferSkip `json:"skipped"`
}

TransferResult is the outcome of a batch transfer.

func MoveItemsBetweenContainers added in v1.0.0

func MoveItemsBetweenContainers(slot *SaveSlot, handles []uint32, direction TransferDirection, opts *TransferOptions) (TransferResult, error)

MoveItemsBetweenContainers physically relocates item records between Inventory.CommonItems and Storage.CommonItems. The GaItem array and GaMap are NOT touched — the handle stays alive on the destination side and the underlying GaItem record (with all its metadata: upgrade level, infusion, attached AoW gem) is preserved across the transfer.

For non-stackable items (handle prefixes 0x80 Weapon, 0x90 Armor, 0xC0 AoW): the source record is cleared and an empty destination slot receives the same handle, the original quantity, and a freshly-assigned Index (per add-like rules for the dest side). Caps are ignored (records hold qty=1).

For stackable items (handle prefixes 0xA0 Accessory/Talisman and 0xB0 Goods), and for any case where the destination already has a record with the same handle: the move is cap-aware. The destination cap is read from opts.DestCaps[handle]; if missing or 0, the handle is skipped with reason SkipReasonMissingCap (no silent unbounded merge). When the source quantity exceeds the available space (cap - existing_dst_qty), the move is partial: destination is filled to cap, source retains the remainder, the handle is reported in both Moved (>0 qty moved) and Skipped (reason SkipReasonDestAtCap with MovedQty/RemainingQty populated). When the destination is already at cap, no movement happens and the skip reason is SkipReasonDestAtCap with MovedQty=0.

Equipped items (handles referenced by ChrAsmEquipment) are rejected when moving Inventory → Storage with skip reason SkipReasonEquipped. No equipped check is performed for Storage → Inventory.

The function never partial-fails the batch: invalid handles are recorded in Skipped, valid handles are processed independently. After the batch, both common_item_count headers are reconciled defensively, and slot.Storage.CommonItems is rebuilt from binary to drop stale entries.

type TransferSkip added in v1.0.0

type TransferSkip struct {
	Handle       uint32 `json:"handle"`
	Reason       string `json:"reason"`                 // see SkipReason* constants
	MovedQty     uint32 `json:"movedQty,omitempty"`     // qty actually transferred (partial-cap only)
	RemainingQty uint32 `json:"remainingQty,omitempty"` // qty left in source (partial-cap only)
}

TransferSkip describes a single handle outcome that was not (fully) moved. MovedQty and RemainingQty are populated only for partial transfers (reason "dest_at_cap" with non-zero MovedQty) — other reasons leave them at 0.

type TrophyEquipData

type TrophyEquipData struct {
	Unk0x0  uint32
	Unk0x4  [16]byte
	Unk0x14 [16]byte
	Unk0x24 [16]byte
}

TrophyEquipData — fixed 52-byte block of opaque equipment metadata. Layout: unk0x0 u32 | unk0x4 [16]byte | unk0x14 [16]byte | unk0x24 [16]byte Reference: tmp/repos/er-save-manager/parser/equipment.py:TrophyEquipData

func (*TrophyEquipData) Read

func (t *TrophyEquipData) Read(r *Reader) error

func (*TrophyEquipData) Write

func (t *TrophyEquipData) Write(w *SectionWriter)

type TutorialData

type TutorialData struct {
	Unk0x0 uint16
	Unk0x2 uint16
	Data   []byte // serialized length is len(Data); Size header is rewritten on Write
}

TutorialData — variable-size tutorial progress block. Layout: unk0x0 u16 | unk0x2 u16 | size u32 | data[size]

Inside `data`: u32 count followed by remaining bytes treated as u32 IDs by er-save-manager. We keep the inner block as raw bytes — the count is encoded as the first 4 bytes of Data and we don't need to interpret it. Reference: tmp/repos/er-save-manager/parser/world.py:TutorialData

func (*TutorialData) ByteSize

func (t *TutorialData) ByteSize() int

ByteSize returns the serialized length: 8-byte header + payload.

func (*TutorialData) Read

func (t *TutorialData) Read(r *Reader) error

func (*TutorialData) Write

func (t *TutorialData) Write(w *SectionWriter)

type UnknownReason added in v1.1.0

type UnknownReason string

UnknownReason explains why a record ended up with ResolutionUnknown. It is its own type (not Resolution) because the two describe different axes: Resolution is the terminal status, UnknownReason is the cause behind an Unknown status. Each reason maps to a distinct repair code and must not be collapsed.

const (
	UnknownReasonUnknownHandleType UnknownReason = "unknown_handle_type" // handle prefix is not a known GaItem type
	UnknownReasonMissingDBEntry    UnknownReason = "missing_db_entry"    // known prefix, but itemID absent from DB
)

type ValidationCoverage added in v1.1.0

type ValidationCoverage struct {
	TotalPhysical           int            `json:"totalPhysical"`
	Resolved                int            `json:"resolved"`
	KnownDB                 int            `json:"knownDB"`
	TechnicalPlaceholder    int            `json:"technicalPlaceholder"`
	Unknown                 int            `json:"unknown"`
	ResolutionChecksApplied int            `json:"resolutionChecksApplied"`
	StructuralChecksApplied int            `json:"structuralChecksApplied"`
	CategoryChecksApplied   int            `json:"categoryChecksApplied"`
	PerCategory             map[string]int `json:"perCategory"`
	UnknownByReason         map[string]int `json:"unknownByReason"`
}

ValidationCoverage is a measurable, issue-independent report of how many physical records the scanner read and to what depth they were checked.

Invariants (Prompt 12):

TotalPhysical           = KnownDB + TechnicalPlaceholder + Unknown
Resolved                = KnownDB + TechnicalPlaceholder
ResolutionChecksApplied = TotalPhysical   (the resolver classified every
                                            physical record — this is what
                                            BuildCoverageReport itself did)
StructuralChecksApplied = 0 until the scanner runs; the pipeline fills it
                          with the count the structural scanner actually
                          processed (see ScanRepairIssuesWithCoverage). The
                          builder never claims structural checks it did not
                          execute.
CategoryChecksApplied   = 0 from the builder; the pipeline fills it with the
                          number of KnownDB records the container/quantity
                          validator actually executed (see
                          ScanRepairIssuesWithCoverage). Only KnownDB records
                          are category-checked, so this is ≤ KnownDB.

PerCategory counts only records that resolved to a recognised DB category, so its values sum to KnownDB minus any KnownDB record with an empty category — it is NOT expected to sum to TotalPhysical.

func BuildCoverageReport added in v1.1.0

func BuildCoverageReport(records []ResolvedRecord) ValidationCoverage

BuildCoverageReport tallies a resolved record collection into a coverage report. It reads only the already-resolved records — it never rescans the slot, so its semantics can never diverge from the scanner's.

It reports ResolutionChecksApplied (work the resolver genuinely did) but leaves StructuralChecksApplied at 0: the structural scanner has not run at this point, and the builder must not claim checks it did not execute. The pipeline sets StructuralChecksApplied afterwards (ScanRepairIssuesWithCoverage).

type WondrousPhysickOccurrence added in v1.0.0

type WondrousPhysickOccurrence struct {
	Scope  string `json:"scope"`
	Row    int    `json:"row"`
	Handle uint32 `json:"handle"`
	ItemID uint32 `json:"itemId"`
}

func ScanDuplicateWondrousPhysick added in v1.0.0

func ScanDuplicateWondrousPhysick(slot *SaveSlot) []WondrousPhysickOccurrence

type WorldAreaTime

type WorldAreaTime struct {
	Hour   uint32
	Minute uint32
	Second uint32
}

WorldAreaTime — 12 bytes (hour, minute, second as u32 each).

func (*WorldAreaTime) Read

func (t *WorldAreaTime) Read(r *Reader) error

func (*WorldAreaTime) Write

func (t *WorldAreaTime) Write(sw *SectionWriter)

type WorldAreaWeather

type WorldAreaWeather struct {
	AreaID      uint16
	WeatherType uint16
	Timer       uint32
	Padding     uint32
}

WorldAreaWeather — 12 bytes. Layout: area_id u16 | weather_type u16 | timer u32 | padding u32

func (*WorldAreaWeather) Read

func (w *WorldAreaWeather) Read(r *Reader) error

func (*WorldAreaWeather) Write

func (w *WorldAreaWeather) Write(sw *SectionWriter)

type WorldGeomBlock

type WorldGeomBlock struct {
	FieldArea     SizePrefixedBlob
	WorldArea     SizePrefixedBlob
	WorldGeomMan  SizePrefixedBlob
	WorldGeomMan2 SizePrefixedBlob
	RendMan       SizePrefixedBlob
}

WorldGeomBlock — combined post-event_flags world block: field_area + world_area + world_geom_man + world_geom_man2 + rend_man. Each is a SizePrefixedBlob with its own sanity ceiling. Reference: tmp/repos/er-save-manager/parser/world.py + slot_rebuild.py

func (*WorldGeomBlock) ByteSize

func (b *WorldGeomBlock) ByteSize() int

ByteSize returns the total serialized size of the 5 size-prefixed blobs.

func (*WorldGeomBlock) Read

func (b *WorldGeomBlock) Read(r *Reader) error

func (*WorldGeomBlock) Write

func (b *WorldGeomBlock) Write(w *SectionWriter)

type WorldHead

type WorldHead struct {
	Horse             RideGameData
	ControlByteMaybe  uint8
	BloodStain        BloodStain
	UnkGameDataMan120 uint32 // gamedataman_0x120 or _0x130 depending on version
	UnkGameDataMan88  uint32 // gamedataman_0x88
}

WorldHead — first block after `unlocked_regions`: horse(40) + control_byte(1) + blood_stain(68) + unk_gdm_120(4) + unk_gdm_88(4) Total 117 bytes. Field naming follows er-save-manager.

func (*WorldHead) Read

func (h *WorldHead) Read(r *Reader) error

func (*WorldHead) Write

func (h *WorldHead) Write(w *SectionWriter)

type Writer

type Writer struct {
	// contains filtered or unexported fields
}

func NewWriter

func NewWriter(w io.Writer) *Writer

func (*Writer) WriteBytes

func (w *Writer) WriteBytes(v []byte) error

func (*Writer) WriteI32

func (w *Writer) WriteI32(v int32) error

func (*Writer) WriteU8

func (w *Writer) WriteU8(v uint8) error

func (*Writer) WriteU16

func (w *Writer) WriteU16(v uint16) error

func (*Writer) WriteU32

func (w *Writer) WriteU32(v uint32) error

func (*Writer) WriteU64

func (w *Writer) WriteU64(v uint64) error

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL