Files
d2go/pkg/memory/item.go
2026-02-17 02:54:20 +01:00

861 lines
25 KiB
Go

package memory
import (
"slices"
"sort"
"strings"
"github.com/hectorgimenez/d2go/pkg/data"
"github.com/hectorgimenez/d2go/pkg/data/item"
"github.com/hectorgimenez/d2go/pkg/data/skill"
"github.com/hectorgimenez/d2go/pkg/data/stat"
"github.com/hectorgimenez/d2go/pkg/data/state"
"github.com/hectorgimenez/d2go/pkg/utils"
)
func (gd *GameReader) Inventory(rawPlayerUnits RawPlayerUnits, hover data.HoverData) data.Inventory {
mainPlayer := rawPlayerUnits.GetMainPlayer()
baseAddr := gd.Process.moduleBaseAddressPtr + gd.offset.UnitTable + (4 * 1024)
unitTableBuffer := gd.Process.ReadBytesFromMemory(baseAddr, 128*8)
// Process shared stash data - build a UnitID→page mapping for ALL shared stash units.
// Post-patch D2R has 5 shared stash units (3 original shared tabs + DLC tabs).
stashUnitIDToPage := make(map[uint]uint) // UnitID → 1-based page number
stashPlayerUnitOrder := make([]uint, 0, 6) // order values, sorted
stashPlayerUnits := make(map[uint]RawPlayerUnit) // order → unit
for _, pu := range rawPlayerUnits {
if pu.States.HasState(state.Sharedstash) {
order := gd.ReadUInt(pu.Address+0xD8, Uint64)
stashPlayerUnitOrder = append(stashPlayerUnitOrder, order)
stashPlayerUnits[order] = pu
}
}
slices.Sort(stashPlayerUnitOrder)
for i, orderKey := range stashPlayerUnitOrder {
uid := uint(stashPlayerUnits[orderKey].UnitID)
stashUnitIDToPage[uid] = uint(i + 1) // pages are 1-based
}
// Gold
inventoryGold, _ := mainPlayer.BaseStats.FindStat(stat.Gold, 0)
mainPlayerStashedGold, _ := mainPlayer.BaseStats.FindStat(stat.StashGold, 0)
stashedGold := [6]int{mainPlayerStashedGold.Value, 0, 0, 0, 0, 0}
for i, puKey := range stashPlayerUnitOrder {
if i > 5 {
break // max 6 shared stash pages
}
if stashGold, found := stashPlayerUnits[puKey].BaseStats.FindStat(stat.StashGold, 0); found {
stashedGold[i+1] = stashGold.Value
}
}
inventory := data.Inventory{
Gold: inventoryGold.Value,
StashedGold: stashedGold,
SharedStashPages: len(stashPlayerUnitOrder),
}
belt := data.Belt{}
// Early return if not in-game to avoid any crash
if mainPlayer.Area == 0 {
inventory.Belt = belt
return inventory
}
type socketInfo struct {
item *data.Item
position int // Store X position
}
socketedItemsMap := make(map[data.UnitID][]socketInfo, 120) // Up to ~120 items could be socketable
baseItemsMap := make(map[data.UnitID]*data.Item, 120) // Same number of potential base items
allItems := make([]*data.Item, 0, 480) // max capacity: 400 (stashes) + 40 (inv) + 12 (cube) + 12 (equipped) + 16 (belt)
// Pre-allocate buffers for repeated use
var itemDataBuffer = make([]byte, 144)
var unitDataBuffer = make([]byte, 144)
var pathBuffer = make([]byte, 144)
// Process all items in a single pass
for i := 0; i < 128; i++ {
itemOffset := 8 * i
itemUnitPtr := uintptr(ReadUIntFromBuffer(unitTableBuffer, uint(itemOffset), Uint64))
for itemUnitPtr > 0 {
nextItemPtr := uintptr(gd.Process.ReadUInt(itemUnitPtr+0x158, Uint64))
// Read basic item data into pre-allocated buffer
if err := gd.Process.ReadIntoBuffer(itemUnitPtr, itemDataBuffer); err != nil {
itemUnitPtr = nextItemPtr
continue
}
itemType := ReadUIntFromBuffer(itemDataBuffer, 0x00, Uint32)
// Skip non-item entries early
if itemType != 4 {
itemUnitPtr = nextItemPtr
continue
}
txtFileNo := ReadUIntFromBuffer(itemDataBuffer, 0x04, Uint32)
unitID := ReadUIntFromBuffer(itemDataBuffer, 0x08, Uint32)
// itemLoc = 0 in inventory, 1 equipped, 2 in belt, 3 on ground, 4 cursor, 5 dropping, 6 socketed
itemLoc := ReadUIntFromBuffer(itemDataBuffer, 0x0C, Uint32)
unitDataPtr := uintptr(ReadUIntFromBuffer(itemDataBuffer, 0x10, Uint64))
if err := gd.Process.ReadIntoBuffer(unitDataPtr, unitDataBuffer); err != nil {
itemUnitPtr = nextItemPtr
continue
}
flags := ReadUIntFromBuffer(unitDataBuffer, 0x18, Uint32)
invPage := ReadUIntFromBuffer(unitDataBuffer, 0x55, Uint8)
itemQuality := ReadUIntFromBuffer(unitDataBuffer, 0x00, Uint32)
itemOwnerNPC := ReadUIntFromBuffer(unitDataBuffer, 0x0C, Uint32)
// Link to uniqueitems.txt, setitems.txt
txtUniqueSet := int32(gd.Process.ReadUInt(unitDataPtr+0x34, Uint32))
pathPtr := uintptr(ReadUIntFromBuffer(itemDataBuffer, 0x38, Uint64))
if err := gd.Process.ReadIntoBuffer(pathPtr, pathBuffer); err != nil {
itemUnitPtr = nextItemPtr
continue
}
// Item coordinates (X, Y)
itemX := ReadUIntFromBuffer(pathBuffer, 0x10, Uint16)
itemY := ReadUIntFromBuffer(pathBuffer, 0x14, Uint16)
// Create item structure
itm := &data.Item{
ID: int(txtFileNo),
UnitID: data.UnitID(unitID),
Name: item.GetNameByEnum(txtFileNo),
Quality: item.Quality(itemQuality),
Position: data.Position{
X: int(itemX),
Y: int(itemY),
},
IsHovered: hover.IsHovered && hover.UnitType == 4 && hover.UnitID == data.UnitID(unitID),
Sockets: make([]data.Item, 0),
UniqueSetID: txtUniqueSet,
}
// Set item properties
setProperties(itm, uint32(flags))
// Read rare affixes
rarePrefix := int16(gd.Process.ReadUInt(unitDataPtr+0x42, Uint16))
rareSuffix := int16(gd.Process.ReadUInt(unitDataPtr+0x44, Uint16))
//autoAffix := int16(gd.Process.ReadUInt(unitDataPtr+0x46, Uint16))
// Read magic affixes
var prefixes [3]int16
var suffixes [3]int16
for i := 0; i < 3; i++ {
prefixes[i] = int16(gd.Process.ReadUInt(unitDataPtr+0x48+uintptr(i*2), Uint16))
suffixes[i] = int16(gd.Process.ReadUInt(unitDataPtr+0x4E+uintptr(i*2), Uint16))
}
itm.Affixes = data.ItemAffixes{
Rare: struct {
Prefix int16
Suffix int16
}{
Prefix: rarePrefix,
Suffix: rareSuffix,
},
Magic: struct {
Prefixes [3]int16
Suffixes [3]int16
}{
Prefixes: prefixes,
Suffixes: suffixes,
},
}
maxAffixReq := 0
if itm.Identified {
switch itm.Quality {
case item.QualityUnique:
// find matching item (uniqueitems.txt)
for _, uniqueInfo := range item.UniqueItems {
if uniqueInfo.ID == int(txtUniqueSet) {
itm.IdentifiedName = uniqueInfo.Name
itm.LevelReq = uniqueInfo.LevelReq
break
}
}
case item.QualitySet:
// find matching item (setitems.txt)
for setItemName, setItemInfo := range item.SetItems {
if setItemInfo.ID == int(txtUniqueSet) {
itm.IdentifiedName = string(setItemName)
itm.LevelReq = setItemInfo.LevelReq
break
}
}
case item.QualityRare, item.QualityCrafted:
// Set item name from rare affixes
if prefix, exists := item.RarePrefixDesc[int(rarePrefix)]; exists {
if suffix, exists := item.RareSuffixDesc[int(rareSuffix)]; exists {
itm.IdentifiedName = prefix.Name + " " + suffix.Name
}
}
// Get level requirements from magic affixes
for _, prefixID := range prefixes {
if prefix, exists := item.MagicPrefixDesc[int(prefixID)]; exists && prefixID != 0 {
if prefix.LevelReq > maxAffixReq {
maxAffixReq = prefix.LevelReq
}
}
}
for _, suffixID := range suffixes {
if suffix, exists := item.MagicSuffixDesc[int(suffixID)]; exists && suffixID != 0 {
if suffix.LevelReq > maxAffixReq {
maxAffixReq = suffix.LevelReq
}
}
}
case item.QualityMagic:
var prefixParts []string
var suffixParts []string
// Get all prefixes
for _, prefixID := range prefixes {
if prefix, exists := item.MagicPrefixDesc[int(prefixID)]; exists && prefixID != 0 {
prefixParts = append(prefixParts, prefix.Name)
if prefix.LevelReq > maxAffixReq {
maxAffixReq = prefix.LevelReq
}
}
}
// Get all suffixes
for _, suffixID := range suffixes {
if suffix, exists := item.MagicSuffixDesc[int(suffixID)]; exists && suffixID != 0 {
suffixParts = append(suffixParts, suffix.Name)
if suffix.LevelReq > maxAffixReq {
maxAffixReq = suffix.LevelReq
}
}
}
// Construct name: prefixes + base name + suffixes
var nameParts []string
if len(prefixParts) > 0 {
nameParts = append(nameParts, prefixParts...)
}
nameParts = append(nameParts, itm.Desc().Name)
if len(suffixParts) > 0 {
nameParts = append(nameParts, suffixParts...)
}
itm.IdentifiedName = strings.Join(nameParts, " ")
}
}
// Set runeword name if applicable
if itm.IsRuneword {
if runeword, exists := item.RunewordIDMap[prefixes[0]]; exists {
itm.RunewordName = runeword
}
}
// Determine item location
location := item.LocationUnknown
switch itemLoc {
case 0:
// Check shared stash ownership dynamically via UnitID map.
// Also keep legacy checks (ownerNPC 2/3/4) for older D2R versions.
if page, ok := stashUnitIDToPage[itemOwnerNPC]; ok {
location = item.LocationSharedStash
invPage = page
} else if itemOwnerNPC == 2 && len(stashUnitIDToPage) == 0 {
location = item.LocationSharedStash
invPage = 1
} else if itemOwnerNPC == 3 && len(stashUnitIDToPage) == 0 {
location = item.LocationSharedStash
invPage = 2
} else if itemOwnerNPC == 4 && len(stashUnitIDToPage) == 0 {
location = item.LocationSharedStash
invPage = 3
} else if itemOwnerNPC == 4294967295 {
if 0x00002000&flags != 0 {
location = item.LocationVendor
} else {
// DLC stash tab items: ownerNPC=0xFFFFFFFF, no vendor flag.
// Classify by item type: gems, runes, or materials.
location = classifyDLCTabItem(txtFileNo)
}
} else if data.UnitID(itemOwnerNPC) == mainPlayer.UnitID || itemOwnerNPC == 1 {
if invPage == 0 {
location = item.LocationInventory
} else if invPage == 3 {
location = item.LocationCube
invPage = 0
} else {
location = item.LocationStash
invPage = 0
}
}
case 1:
isMercItem := (flags & 0x800000) != 0
if data.UnitID(itemOwnerNPC) == mainPlayer.UnitID || itemOwnerNPC == 1 {
location = item.LocationEquipped
if itm.Type().Code == item.TypeBelt {
belt.Name = itm.Name
}
} else if isMercItem {
location = item.LocationMercenary
}
case 2:
if data.UnitID(itemOwnerNPC) == mainPlayer.UnitID || itemOwnerNPC == 1 {
location = item.LocationBelt
}
case 3, 5:
location = item.LocationGround
case 6:
location = item.LocationSocket
case 4:
location = item.LocationCursor
}
// Set body location if equipped
bodyLoc := item.LocNone
equipSlotFlags := uint16(gd.Process.ReadUInt(unitDataPtr+uintptr(0x54), Uint16))
if equipSlotFlags&0xFF00 == 0xFF00 {
equipSlot := uint8(equipSlotFlags & 0xFF)
switch equipSlot {
case 0x01:
bodyLoc = item.LocHead
case 0x02:
bodyLoc = item.LocNeck
case 0x03:
bodyLoc = item.LocTorso
case 0x04:
bodyLoc = item.LocLeftArm
case 0x05:
bodyLoc = item.LocRightArm
case 0x06:
bodyLoc = item.LocLeftRing
case 0x07:
bodyLoc = item.LocRightRing
case 0x08:
bodyLoc = item.LocBelt
case 0x09:
bodyLoc = item.LocFeet
case 0x0A:
bodyLoc = item.LocGloves
case 0x0B:
bodyLoc = item.LocLeftArmSecondary
case 0x0C:
bodyLoc = item.LocRightArmSecondary
}
}
itm.Location = item.Location{
LocationType: location,
BodyLocation: bodyLoc,
Page: int(invPage),
}
// We don't care about the inventory we don't know where they are, probably previous games or random crap
if location == item.LocationUnknown {
itemUnitPtr = nextItemPtr
continue
}
// Read item stats
statsListExPtr := uintptr(ReadUIntFromBuffer(itemDataBuffer, 0x88, Uint64))
itm.BaseStats, itm.Stats = gd.getItemStats(statsListExPtr)
// // Read stack quantity from UnitData+0x9C (DLC tabs store quantity here instead of in stats)
// stackQty := gd.Process.ReadUInt(unitDataPtr+0x9C, Uint32)
// found := false
// for i := range itm.Stats {
// if itm.Stats[i].ID == stat.Quantity && itm.Stats[i].Layer == 0 {
// itm.Stats[i].Value = int(stackQty)
// found = true
// break
// }
// }
// if !found && stackQty > 0 {
// itm.Stats = append(itm.Stats, stat.Data{ID: stat.Quantity, Value: int(stackQty), Layer: 0})
// }
// Process socket information
if location == item.LocationSocket {
if itm.Desc().Code == "jew" {
// Base requirement for jewels
itm.LevelReq = itm.Desc().RequiredLevel
// For magic/rare jewels, check affixes
if itm.Quality == item.QualityMagic || itm.Quality == item.QualityRare {
itm.LevelReq = updateMaxReqFromAffixes(itm.LevelReq, itm.Affixes)
}
// Rainbow facets
} else if itm.Quality == item.QualityUnique {
for _, uniqueInfo := range item.UniqueItems {
if uniqueInfo.Code == itm.Desc().Code {
itm.LevelReq = uniqueInfo.LevelReq
break
}
}
} else {
// Normal socketed items (runes,gems) just use base requirement
itm.LevelReq = itm.Desc().RequiredLevel
}
itemExtraData := uintptr(gd.Process.ReadUInt(unitDataPtr+0xA0, Uint64))
if itemExtraData != 0 {
parentInfoPtr := uintptr(gd.Process.ReadUInt(itemExtraData+0x08, Uint64))
if parentInfoPtr != 0 {
// Read parent unit ID directly from base item memory structure
if err := gd.Process.ReadIntoBuffer(parentInfoPtr, itemDataBuffer); err == nil {
parentUnitID := data.UnitID(ReadUIntFromBuffer(itemDataBuffer, 0x08, Uint32))
socketedItemsMap[parentUnitID] = append(socketedItemsMap[parentUnitID], socketInfo{
item: itm,
position: itm.Position.X,
})
}
}
}
} else {
// Check if item has sockets
if numSockets, _ := itm.Stats.FindStat(stat.NumSockets, 0); numSockets.Value > 0 {
baseItemsMap[itm.UnitID] = itm
}
}
// Add to appropriate collections
if location == item.LocationBelt {
belt.Items = append(belt.Items, *itm)
} else if location != item.LocationSocket {
allItems = append(allItems, itm)
}
itemUnitPtr = nextItemPtr
}
}
// Link sockets to base items
for baseUnitID, baseItem := range baseItemsMap {
numSockets, _ := baseItem.Stats.FindStat(stat.NumSockets, 0)
if numSockets.Value == 0 {
continue
}
// Get socket list for this base item
sockets := socketedItemsMap[baseUnitID]
if len(sockets) == 0 {
continue
}
if len(sockets) > numSockets.Value {
continue
}
// Sort by position if we have multiple sockets
if len(sockets) > 1 {
sort.Slice(sockets, func(i, j int) bool {
return sockets[i].position < sockets[j].position
})
}
// Validate socket positions and build final list in one pass (allow partial sockets)
baseItem.Sockets = make([]data.Item, 0, len(sockets))
for i, socket := range sockets {
if socket.position != i {
baseItem.Sockets = nil // Reset if positions are invalid
break
}
baseItem.Sockets = append(baseItem.Sockets, *socket.item)
}
}
// Build final inventory
inventory.AllItems = make([]data.Item, len(allItems))
for i, itm := range allItems {
baseDesc := itm.Desc()
maxReq := calculateItemLevelReq(itm, baseDesc)
// Check magic/rare affixes
if itm.Identified && (itm.Quality == item.QualityMagic || itm.Quality == item.QualityRare || itm.Quality == item.QualityCrafted) {
maxReq = updateMaxReqFromAffixes(maxReq, itm.Affixes)
}
// Check socketed items
for _, socketItem := range itm.Sockets {
socketBaseDesc := socketItem.Desc()
socketReq := calculateItemLevelReq(&socketItem, socketBaseDesc)
if socketReq > maxReq {
maxReq = socketReq
}
// Check affixes on magic socketed items
if socketItem.Quality == item.QualityMagic {
maxReq = updateMaxReqFromAffixes(maxReq, socketItem.Affixes)
}
}
itm.LevelReq = maxReq
// Update stat 92 (LevelRequire) so we can use with .nip
for it, s := range itm.Stats {
if s.ID == stat.LevelRequire {
itm.Stats[it].Value = maxReq
break
}
}
inventory.AllItems[i] = *itm
}
// Sort items by distance if needed, using pre-calculated distances
if len(inventory.AllItems) > 0 {
// Pre-calculate distances to avoid recalculating in sort comparisons
distances := make([]int, len(inventory.AllItems))
for i, invItem := range inventory.AllItems {
distances[i] = utils.DistanceFromPoint(mainPlayer.Position, invItem.Position)
}
sort.SliceStable(inventory.AllItems, func(i, j int) bool {
return distances[i] < distances[j]
})
}
inventory.Belt = belt
return inventory
}
func (gd *GameReader) getItemStats(statsListExPtr uintptr) (stat.Stats, stat.Stats) {
// Initial full and base stats extraction
fullStats := gd.getStatsList(statsListExPtr + 0xA8)
baseStats := gd.getStatsList(statsListExPtr + 0x30)
// Create empty LevelRequire stat .We will update it from inventory
if _, found := fullStats.FindStat(stat.LevelRequire, 0); !found {
fullStats = append(fullStats, stat.Data{
ID: stat.LevelRequire,
Value: 0,
Layer: 0,
})
}
// Flags and last stat list pointers
flags := gd.Process.ReadUInt(statsListExPtr+0x1C, Uint64)
lastStatsList := uintptr(gd.Process.ReadUInt(statsListExPtr+0x90, Uint64))
// If the special flag isn't set, return the current base and full stats
if (flags & 0x80000000) == 0 {
return baseStats, fullStats
}
// Prepare for additional stats processing
additionalStats := stat.Stats{}
statListPtr := lastStatsList
// Traverse the stat lists to accumulate additional stats
for statListPtr != 0 {
statListFlags := gd.Process.ReadUInt(statListPtr+0x1C, Uint64)
// If we hit a condition where no further traversal is needed, break
if (0x40 & statListFlags & 0xFFFFDFFF) != 0 {
break
}
// Move to the previous stat list
statListPtr = uintptr(gd.Process.ReadUInt(statListPtr+0x48, Uint64))
}
// If we found a valid previous stat list
if statListPtr != 0 {
additionalBaseStats := gd.getStatsList(statListPtr + 0x30)
// Add only the additional stats that are not already present in fullStats
for _, statM := range additionalBaseStats {
if _, found := fullStats.FindStat(statM.ID, statM.Layer); !found {
additionalStats = append(additionalStats, statM)
}
}
}
// Merge additional stats into fullStats (but not into baseStats)
fullStats = append(fullStats, additionalStats...)
// Handle base stats from potential modifiers and ensure they don't pollute baseStats
statListPtr = lastStatsList
for statListPtr != 0 {
statListFlags := gd.Process.ReadUInt(statListPtr+0x1C, Uint64)
if statListFlags != 0 {
modifierBaseStats := gd.getStatsList(statListPtr + 0x30)
for _, mStat := range modifierBaseStats {
// Update fullStats
if existingStat, found := fullStats.FindStat(mStat.ID, mStat.Layer); found {
existingStat.Value = mStat.Value
} else {
fullStats = append(fullStats, mStat)
}
// Add to baseStats only if this stat qualifies as a base stat
if (flags&0x80000000) != 0 && statListFlags == 0x80000000 {
if _, found := baseStats.FindStat(mStat.ID, mStat.Layer); !found {
baseStats = append(baseStats, mStat)
}
}
}
}
// Move to the previous stat list
statListPtr = uintptr(gd.Process.ReadUInt(statListPtr+0x48, Uint64))
}
return baseStats, fullStats
}
func setProperties(item *data.Item, flags uint32) {
if 0x4000000&flags != 0 {
item.IsRuneword = true
}
if 0x1000000&flags != 0 {
item.IsNamed = true
}
if 0x400000&flags != 0 {
item.Ethereal = true
}
if 0x20000&flags != 0 {
item.IsStartItem = true
}
if 0x10000&flags != 0 {
item.IsEar = true
}
if 0x2000&flags != 0 {
item.InTradeOrStoreScreen = true // If item is sold to vendor or placed in trade with another player
}
if 0x800&flags != 0 {
item.HasSockets = true
}
if 0x100&flags != 0 {
item.IsBroken = true
}
if 0x10&flags != 0 {
item.Identified = true
}
// Only jewels and runes, gems don't work
if 0x8&flags != 0 {
item.IsInSocket = true
}
if 0x1&flags != 0 {
item.HasBeenEquipped = true // Item was recently equipped (same game)
}
}
func updateMaxReqFromAffixes(currentMax int, affixes data.ItemAffixes) int {
maxReq := currentMax
for _, prefixID := range affixes.Magic.Prefixes {
if prefix, exists := item.MagicPrefixDesc[int(prefixID)]; exists && prefixID != 0 {
if prefix.LevelReq > maxReq {
maxReq = prefix.LevelReq
}
}
}
for _, suffixID := range affixes.Magic.Suffixes {
if suffix, exists := item.MagicSuffixDesc[int(suffixID)]; exists && suffixID != 0 {
if suffix.LevelReq > maxReq {
maxReq = suffix.LevelReq
}
}
}
return maxReq
}
func staffModLevelRequirement(itm *data.Item, baseDesc item.Description) int {
eligibleTypes := map[string]bool{
item.TypeScepter: true,
item.TypeWand: true,
item.TypeStaff: true,
item.TypeOrb: true,
item.TypeHandtoHand: true,
item.TypeHandtoHand2: true,
item.TypePelt: true,
item.TypePrimalHelm: true,
}
if !eligibleTypes[baseDesc.Type] {
return 0
}
maxReq := 0
checkStats := func(stats stat.Stats) {
for _, s := range stats {
if s.ID == stat.SingleSkill {
if skill, ok := skill.Skills[skill.ID(s.Layer)]; ok && skill.ReqLevel > maxReq {
maxReq = skill.ReqLevel
}
}
}
}
checkStats(itm.BaseStats)
checkStats(itm.Stats)
return maxReq
}
func calculateItemLevelReq(itm *data.Item, baseDesc item.Description) int {
// Start with base item's requirement
maxReq := baseDesc.RequiredLevel
// Handle Crafted items first as they have special calculation rules
if itm.Quality == item.QualityCrafted {
// Count number of affixes and find highest level requirement
affixCount := 0
maxAffixReq := 0
for _, prefixID := range itm.Affixes.Magic.Prefixes {
if prefix, exists := item.MagicPrefixDesc[int(prefixID)]; exists && prefixID != 0 {
affixCount++
if prefix.LevelReq > maxAffixReq {
maxAffixReq = prefix.LevelReq
}
}
}
for _, suffixID := range itm.Affixes.Magic.Suffixes {
if suffix, exists := item.MagicSuffixDesc[int(suffixID)]; exists && suffixID != 0 {
affixCount++
if suffix.LevelReq > maxAffixReq {
maxAffixReq = suffix.LevelReq
}
}
}
// Base calculation
levelReq := maxAffixReq + (3 * affixCount)
// Add 10 to final level req
levelReq += 10
// Cap at 98 if needed (Maximum possible)
if levelReq > 99 {
levelReq = 98
}
return levelReq
}
// Handle Unique/Set items and their special upgrade cases
if itm.Quality == item.QualityUnique || itm.Quality == item.QualitySet {
itemCode := baseDesc.Code
normalCode := baseDesc.NormalCode
exceptionalCode := baseDesc.UberCode
eliteCode := baseDesc.UltraCode
// Find the original item tier by looking up the unique/set item
var originalCode string
if itm.Quality == item.QualityUnique {
for _, uniqueInfo := range item.UniqueItems {
if uniqueInfo.ID == int(itm.UniqueSetID) {
originalCode = uniqueInfo.Code
break
}
}
} else {
for _, setItemInfo := range item.SetItems {
if setItemInfo.ID == int(itm.UniqueSetID) {
originalCode = setItemInfo.Code
break
}
}
}
// Apply upgrade requirements
if itemCode == eliteCode {
if originalCode == normalCode {
// Double upgraded: Normal -> Exceptional -> Elite
maxReq += 12 // +5 for exceptional, +7 for elite
} else if originalCode == exceptionalCode {
// Single upgraded: Exceptional -> Elite
maxReq += 7
}
} else if itemCode == exceptionalCode && originalCode == normalCode {
// Single upgraded: Normal -> Exceptional
maxReq += 5
}
// Check unique/set specific level requirement
if itm.Identified {
if itm.Quality == item.QualityUnique {
for _, uniqueInfo := range item.UniqueItems {
if uniqueInfo.ID == int(itm.UniqueSetID) {
if uniqueInfo.LevelReq > maxReq {
maxReq = uniqueInfo.LevelReq
}
break
}
}
} else if itm.Quality == item.QualitySet {
for _, setItemInfo := range item.SetItems {
if setItemInfo.ID == int(itm.UniqueSetID) {
if setItemInfo.LevelReq > maxReq {
maxReq = setItemInfo.LevelReq
}
break
}
}
}
}
}
if staffModReq := staffModLevelRequirement(itm, baseDesc); staffModReq > maxReq {
maxReq = staffModReq
}
return maxReq
}
// gemTypes is the set of d2go item type codes that correspond to gems.
var gemTypes = map[string]bool{
item.TypeAmethyst: true,
item.TypeDiamond: true,
item.TypeEmerald: true,
item.TypeRuby: true,
item.TypeSapphire: true,
item.TypeTopaz: true,
item.TypeSkull: true,
}
// materialIDs is the set of txt file IDs that the DLC Materials tab auto-sorts.
// Keys (pk1-pk3), Organs (dhn, bey, mbr), Token (toa), Essences (tes, ceh, bet, fed).
var materialIDs = map[uint]bool{
662: true, // KeyOfTerror
663: true, // KeyOfHate
664: true, // KeyOfDestruction
665: true, // DiablosHorn
666: true, // BaalsEye
667: true, // MephistosBrain
668: true, // TokenOfAbsolution
669: true, // TwistedEssenceOfSuffering
670: true, // ChargedEssenceOfHatred
671: true, // BurningEssenceOfTerror
672: true, // FesteringEssenceOfDestruction
}
// classifyDLCTabItem determines which DLC stash tab an item belongs to
// based on its txt file number. Items with ownerNPC=0xFFFFFFFF and no
// vendor flag are DLC tab items; the game auto-sorts them by category.
func classifyDLCTabItem(txtFileNo uint) item.LocationType {
if desc, ok := item.Desc[int(txtFileNo)]; ok {
if desc.Type == item.TypeRune {
return item.LocationRunesTab
}
if gemTypes[desc.Type] {
return item.LocationGemsTab
}
}
if materialIDs[txtFileNo] {
return item.LocationMaterialsTab
}
// Fallback: unknown DLC item — treat as materials tab
return item.LocationMaterialsTab
}