diablo2-web/src/common/items/transaction-cost.ts

1138 lines
43 KiB
TypeScript

/**
* Diablo II 1.13c item transaction pricing, ported from `D2Common.dll`.
*
* - `#10107` (`ITEMS_GetTransactionCost`) is a thunk at `0x6fd79d60` that
* forwards to the real body at `0x6fd78e80`. That body is
* {@link computeTransactionCost113c}.
* - `#10071` at `0x6fd7a210` totals the repair cost of everything the player
* has equipped. That is {@link computeRepairAllCost113c}.
*
* Every branch carries the address it was read from. All arithmetic is int32
* (`Math.imul`, `| 0`, truncating division), as the x86 code does it,
* including the asm's quirks; those are marked `(sic)`.
*
* The input does not depend on any protocol. A client fills it from a
* server item packet; a server fills it from its own unit. The caller must
* supply the item's stat list (`full` and `base` values, the same split as
* `STATLIST_GetUnitStat*` 0x6fd88a80 / 0x6fd88b00). Durability, quantity, base
* defense and the like are read from that list, as the asm does.
*
* Names used below:
* - A = player buy price (vendor's "sell" side; Npc.txt `sell mult`).
* - B = player sell price (vendor's "buy" side; Npc.txt `buy mult`). The asm
* later moves it into `ebp`.
* - C = repair base. The asm later moves it into `edi`.
* - D = divisor applied to affix terms (stack size for stackables).
*/
import { RAW_ARMOR, RAW_ITEM_TYPES, RAW_MAGIC_PREFIX, RAW_MAGIC_SUFFIX, RAW_MISC, RAW_MONSTATS, RAW_SET_ITEMS, RAW_UNIQUE_ITEMS, RAW_WEAPONS } from '../data/canonical-drop-data.ts'
import { CANONICAL_SKILL_COSTS, RAW_BOOKS, RAW_ITEM_STAT_COST, RAW_NPC } from '../data/canonical-vendor-data.ts'
import { SKILLS_BY_ID } from '../data/skills-catalog.ts'
import { parseTable, type D2Table } from '../world/acts.ts'
import { CANONICAL_AUTOMAGIC_TSV } from './automagic.ts'
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/**
* `D2C_TransactionTypes`: 0 = buy, 1 = sell, 2 = gamble, 3 = repair.
*/
export type TransactionType113c = 0 | 1 | 2 | 3
/** One entry of the item's stat list. */
export interface TransactionCostStat {
readonly statId: number
/** Stat param (layer). Skill stats carry the skill id, or `skill << 6 | level`. */
readonly param: number
/** Accumulated value (`0x6fd88a80`). */
readonly value: number
/** Value in the item's own base list (`0x6fd88b00`). Magic properties are excluded. */
readonly base: number
}
/** Item quality as defined by `D2C_ItemQuality` (1 = low ... 9 = tempered). */
export type ItemQuality113c = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
/** What the pricing reads from one item. Ids are the ones the item packet carries. */
export interface TransactionCostInput {
/** ItemsTxt code (`szCode`, 3-4 chars). */
readonly code: string
/**
* `ItemData.wItemFormat` (+0x30), which is the packet's 10-bit `version`
* field. Only the gamble branch reads it (0x6fd78f38).
*/
readonly itemFormat: number
readonly quality: ItemQuality113c
/** `IFLAG_IDENTIFIED` (0x10). */
readonly identified: boolean
/** `IFLAG_ETHEREAL` (0x400000). */
readonly ethereal: boolean
/** `IFLAG_STARTER` (0x20000). */
readonly starterItem: boolean
/** `IFLAG_ISEAR` (0x10000). */
readonly isEar: boolean
/** Ear level (`ItemData+0x48`). Required when `isEar`. */
readonly earLevel?: number | undefined
/** MonStats index (hcIdx) stored in `dwFileIndex` of a body part. Required for `body` items. */
readonly bodyPartMonsterClass?: number | undefined
/** Books.txt row (`wMagicSuffix[0]`, the packet's 5-bit tome/scroll id). Required for `book` items. */
readonly bookId?: number | undefined
/** Packet magic prefix id: 1-based into MagicPrefix.txt, 0 = none. Required for quality 4, and for 7 when the unique id is invalid. */
readonly magicPrefixId?: number | undefined
/** Packet magic suffix id: 1-based into MagicSuffix.txt, 0 = none. */
readonly magicSuffixId?: number | undefined
/** Three packet prefix ids for rare/crafted items (0 = none). */
readonly rarePrefixIds?: readonly [number, number, number] | undefined
/** Three packet suffix ids for rare/crafted items (0 = none). */
readonly rareSuffixIds?: readonly [number, number, number] | undefined
/** Packet automagic id: 1-based into AutoMagic.txt, 0 = none. */
readonly autoAffixId?: number | undefined
/** UniqueItems.txt record index (`dwFileIndex`). Required for quality 7. */
readonly uniqueItemId?: number | undefined
/** SetItems.txt record index (`dwFileIndex`). Required for quality 5. */
readonly setItemId?: number | undefined
/** The item's stat list (full and base values). */
readonly stats: readonly TransactionCostStat[]
/** ItemsTxt codes of the items socketed into this one. */
readonly socketedChildCodes: readonly string[]
}
/** Item-independent state of the transaction. */
export interface TransactionCostContext {
/** Player `STAT_LEVEL` (12). */
readonly playerLevel: number
/** Player full `STAT_ITEM_REDUCEDPRICES` (87). The asm only caps it at 99. */
readonly playerReducedPricesPct: number
readonly difficulty: 0 | 1 | 2
/** MonStats index (hcIdx) of the NPC (`Npc.txt` is searched on it). Not read for gamble. */
readonly npcClass: number
/** Quest-flag test `0x6fd7d0f0`: bit `quest * 16 + state` of the player's quest record. */
readonly isQuestFlagSet: (questId: number, state: 0 | 1) => boolean
readonly tables: TransactionCostTables
}
export interface ItemsTxtCostRecord {
readonly code: string
readonly type: string
readonly type2: string
/** +0xe0 */
readonly cost: number
/** +0xd4 */
readonly gambleCost: number
/** +0xe4 / +0xe8 */
readonly minStack: number
readonly maxStack: number
/** +0x132 */
readonly stackable: boolean
/** +0x112 (byte) */
readonly durability: number
/** +0x113 */
readonly noDurability: boolean
/** +0xfd (byte) */
readonly level: number
/** +0x84 / +0x88 / +0x8c. Empty string when the field is 0. */
readonly normCode: string
readonly uberCode: string
readonly ultraCode: string
/** +0xcc / +0xd0 */
readonly minAc: number
readonly maxAc: number
}
export interface ItemTypeCostRecord {
readonly code: string
readonly equiv1: string
readonly equiv2: string
/** +0x08 */
readonly repair: boolean
/** +0x0e: the `Quiver` word is non-zero */
readonly quiver: boolean
/** +0x10 */
readonly throwable: boolean
/** +0x1f: `StaffMods` is a class (not 7 = none) */
readonly staffMods: boolean
/** +0x21: `Class` < 7 */
readonly classSpecific: boolean
}
export interface CostMultAdd {
readonly name: string
readonly multiply: number
readonly add: number
}
export interface ItemStatCostRecord {
readonly id: number
/** +0x30 */
readonly encode: number
/** +0x14 */
readonly add: number
/** +0x10 */
readonly multiply: number
/** +0x18 */
readonly valShift: number
}
export interface SkillCostRecord {
readonly id: number
/** Skills.txt +0x234 */
readonly costMult: number
/** Skills.txt +0x238 */
readonly costAdd: number
/** Skills.txt +0x174. Undefined when the embedded data has no value; recharge pricing then throws. */
readonly reqLevel: number | undefined
}
export interface BookCostRecord {
readonly name: string
/** +0x10. The pricing does not read it (it uses ItemsTxt cost). */
readonly baseCost: number
/** +0x14 */
readonly costPerCharge: number
}
export interface NpcCostRecord {
readonly name: string
/** +0x00: MonStats hcIdx */
readonly npcClass: number
/** +0x04: Npc.txt `sell mult`, applied to A (player buy price) */
readonly sellMult: number
/** +0x08: Npc.txt `buy mult`, applied to B (player sell price) */
readonly buyMult: number
/** +0x0c */
readonly repMult: number
/** +0x10 */
readonly questFlags: readonly [number, number, number]
/** +0x1c, applied to A */
readonly questSellMults: readonly [number, number, number]
/** +0x28, applied to B */
readonly questBuyMults: readonly [number, number, number]
/** +0x34 */
readonly questRepMults: readonly [number, number, number]
/** +0x40, one per difficulty */
readonly maxBuy: readonly [number, number, number]
}
/** Compiled data the pricing reads. Build it with {@link buildTransactionCostTables}. */
export interface TransactionCostTables {
readonly items: ReadonlyMap<string, ItemsTxtCostRecord>
readonly itemTypes: ReadonlyMap<string, ItemTypeCostRecord>
/** Compiled rows (with "Expansion" rows removed), 0-based. Packet id `n` is row `n - 1`. */
readonly magicPrefixes: readonly CostMultAdd[]
readonly magicSuffixes: readonly CostMultAdd[]
readonly autoMagic: readonly CostMultAdd[]
/** Compiled rows (with "Expansion" rows removed), indexed directly by `dwFileIndex`. */
readonly uniqueItems: readonly CostMultAdd[]
readonly setItems: readonly CostMultAdd[]
readonly itemStatCost: ReadonlyMap<number, ItemStatCostRecord>
readonly skills: ReadonlyMap<number, SkillCostRecord>
readonly books: readonly BookCostRecord[]
readonly npcs: ReadonlyMap<number, NpcCostRecord>
/** Lower-cased `npc` name → MonStats hcIdx, for callers that know NPCs by name. */
readonly npcClassByName: ReadonlyMap<string, number>
/** MonStats `Level`, `Level(N)`, `Level(H)` (+0xaa/+0xac/+0xae) by hcIdx. */
readonly monsterLevels: readonly (readonly [number, number, number])[]
/** Bit split of encode-2/3 stat params (DataTables +0xc6c / +0xc70). */
readonly skillParamShift: number
readonly skillParamMask: number
}
/** Raw tables used to build {@link TransactionCostTables}. */
export interface TransactionCostRawTables {
readonly weapons: D2Table
readonly armor: D2Table
readonly misc: D2Table
readonly itemTypes: D2Table
readonly magicPrefix: D2Table
readonly magicSuffix: D2Table
readonly autoMagic: D2Table
readonly uniqueItems: D2Table
readonly setItems: D2Table
readonly itemStatCost: D2Table
readonly books: D2Table
readonly npc: D2Table
readonly monStats: D2Table
readonly skills: readonly SkillCostRecord[]
}
// ---------------------------------------------------------------------------
// Stat ids read by the asm
// ---------------------------------------------------------------------------
const STAT_ARMORCLASS = 31
const STAT_QUANTITY = 70
const STAT_DURABILITY = 72
const STAT_MAXDURABILITY = 73
const STAT_ITEM_SINGLESKILL = 107
const STAT_ITEM_INDESTRUCTIBLE = 152
const STAT_ITEM_CHARGED_SKILL = 204
const STAT_ITEM_REPLENISH_DURABILITY = 252
const STAT_ITEM_REPLENISH_QUANTITY = 253
const STAT_ITEM_EXTRA_STACK = 254
/** `0x6fd760f0` caps the stack size here. */
const MAX_STACK_CAP = 511
/** `0x6fd78e80` caps reduced prices here (with no lower clamp). */
const REDUCED_PRICES_CAP = 99
/** `0x6fd7a210` loops over body locations 0..12. */
export const REPAIR_ALL_SLOT_COUNT = 13
// ---------------------------------------------------------------------------
// int32 helpers
// ---------------------------------------------------------------------------
const imul = Math.imul
/** Signed `idiv`, truncating; throws on division by zero (#DE in the original). */
function idiv(a: number, b: number, where: string): number {
if (b === 0) throw new Error(`transaction-cost: integer division by zero at ${where}`)
return (a / b) | 0
}
/** Signed division by a power of two as compiled (`cdq; and; add; sar`), which truncates toward zero. */
function sdiv(a: number, d: 1024 | 4096 | 4 | 2): number {
return (a / d) | 0
}
/** Unsigned `div` of an accumulator by the divisor; the result is put back into int32. */
function udiv(acc: number, d: number, where: string): number {
if (d === 0) throw new Error(`transaction-cost: unsigned division by zero at ${where}`)
return Math.floor((acc >>> 0) / (d >>> 0)) | 0
}
/**
* `FOG_10182`-style mul-div at `0x6fd511e0` (eax = a, edx = b, ecx = c).
* Computes `a * b / c` without overflowing, choosing the path by magnitude.
*/
export function fogMulDiv113c(a: number, b: number, c: number): number {
if (c === 0) return 0
const int64 = (): number => Number(BigInt.asIntN(32, (BigInt(a) * BigInt(b)) / BigInt(c)))
if (b > 0x100000) {
return c > b >> 4 ? int64() : imul(idiv(b, c, '0x6fd511e0'), a)
}
if (a > 0x10000) {
return c > a >> 4 ? int64() : imul(idiv(a, c, '0x6fd511e0'), b)
}
return idiv(imul(a, b), c, '0x6fd511e0')
}
// ---------------------------------------------------------------------------
// Lookups
// ---------------------------------------------------------------------------
function requireItem(tables: TransactionCostTables, code: string): ItemsTxtCostRecord {
const rec = tables.items.get(code)
if (!rec) throw new Error(`transaction-cost: unknown ItemsTxt code '${code}'`)
return rec
}
function requireItemType(tables: TransactionCostTables, code: string): ItemTypeCostRecord {
const rec = tables.itemTypes.get(code)
if (!rec) throw new Error(`transaction-cost: unknown ItemTypes code '${code}'`)
return rec
}
function requireSkill(tables: TransactionCostTables, id: number): SkillCostRecord {
const rec = tables.skills.get(id)
if (!rec) throw new Error(`transaction-cost: unknown skill id ${id}`)
return rec
}
function requireIsc(tables: TransactionCostTables, id: number): ItemStatCostRecord {
const rec = tables.itemStatCost.get(id)
if (!rec) throw new Error(`transaction-cost: unknown ItemStatCost id ${id}`)
return rec
}
/**
* Magic affix lookup (`0x6fdc1820`, records 1..count, 0 = none).
* The asm returns null for an out-of-range id. Here a non-zero id outside
* the table throws, because the combined table would otherwise resolve it to
* a record from a neighbouring table.
*/
function affixRecord(rows: readonly CostMultAdd[], id: number, what: string): CostMultAdd | null {
if (!Number.isInteger(id) || id < 0) throw new Error(`transaction-cost: invalid ${what} id ${id}`)
if (id === 0) return null
const rec = rows[id - 1]
if (!rec) throw new Error(`transaction-cost: ${what} id ${id} outside table (${rows.length} rows)`)
return rec
}
function requireDefined<T>(value: T | undefined, what: string): T {
if (value === undefined) throw new Error(`transaction-cost: missing input '${what}'`)
return value
}
/** Full value of `(stat, param 0)`, i.e. `0x6fd88a80`. */
function fullStat(item: TransactionCostInput, statId: number): number {
for (const s of item.stats) if (s.statId === statId && s.param === 0) return s.value | 0
return 0
}
/** Base value of `(stat, param 0)`, i.e. `0x6fd88b00`. */
function baseStat(item: TransactionCostInput, statId: number): number {
for (const s of item.stats) if (s.statId === statId && s.param === 0) return s.base | 0
return 0
}
/** `ITEMS_CheckItemTypeId` at `0x6fd74430`: type or type2, walking Equiv1/Equiv2. */
function isItemOfType(tables: TransactionCostTables, rec: ItemsTxtCostRecord, target: string): boolean {
const seen = new Set<string>()
const queue: string[] = []
if (rec.type) queue.push(rec.type)
if (rec.type2) queue.push(rec.type2)
while (queue.length > 0) {
const code = queue.pop()!
if (code === target) return true
if (seen.has(code)) continue
seen.add(code)
const t = requireItemType(tables, code)
if (t.equiv1) queue.push(t.equiv1)
if (t.equiv2) queue.push(t.equiv2)
}
return false
}
/** `0x6fd74430` for an ItemsTxt code: whether its type/type2 is, or is equivalent to, `target`. */
export function isItemCodeOfType113c(tables: TransactionCostTables, code: string, target: string): boolean {
return isItemOfType(tables, requireItem(tables, code), target)
}
/** The primary ItemTypes record. ItemTypes flags are read from it only, with no equivalences. */
function primaryType(tables: TransactionCostTables, rec: ItemsTxtCostRecord): ItemTypeCostRecord {
return requireItemType(tables, rec.type)
}
/** `0x6fd88ff0`: max durability is the full value of stat 73 only if its base is non-zero. */
function maxDurability(item: TransactionCostInput): number {
return baseStat(item, STAT_MAXDURABILITY) !== 0 ? fullStat(item, STAT_MAXDURABILITY) : 0
}
/** `0x6fd76d40` */
function hasDurability(item: TransactionCostInput, rec: ItemsTxtCostRecord): boolean {
return (
!rec.noDurability &&
rec.durability > 0 &&
maxDurability(item) !== 0 &&
fullStat(item, STAT_ITEM_INDESTRUCTIBLE) <= 0
)
}
/** `0x6fd760f0` */
function maxStack(item: TransactionCostInput, rec: ItemsTxtCostRecord): number {
const n = (rec.maxStack + fullStat(item, STAT_ITEM_EXTRA_STACK)) | 0
return n > MAX_STACK_CAP ? MAX_STACK_CAP : n
}
/** `0x6fd74f00`: a stackable throwing weapon that can be refilled. */
function isRefillableThrowing(tables: TransactionCostTables, item: TransactionCostInput, rec: ItemsTxtCostRecord): boolean {
const t = primaryType(tables, rec)
return t.repair && t.throwable && rec.stackable && !item.ethereal
}
function hasMissingCharges(item: TransactionCostInput): boolean {
for (const s of item.stats) {
if (s.statId !== STAT_ITEM_CHARGED_SKILL) continue
if ((s.value & 0xff) < (s.value >> 8)) return true
}
return false
}
/** `ITEMS_IsRepairable` at `0x6fd77090`. */
export function isItemRepairable113c(item: TransactionCostInput, tables: TransactionCostTables): boolean {
if (!item.identified) return false
if (item.ethereal) return false
if (hasMissingCharges(item)) return true
const rec = requireItem(tables, item.code)
if (!primaryType(tables, rec).repair) return false
if (isRefillableThrowing(tables, item, rec)) return true
return hasDurability(item, rec)
}
// ---------------------------------------------------------------------------
// Cost terms
// ---------------------------------------------------------------------------
interface Triple {
a: number
b: number
c: number
}
/**
* Affix / unique / set term (0x6fd790ce, 0x6fd7954f, 0x6fd79458, 0x6fd79720).
* The overflow guard looks at A only and is applied to all three.
*/
function multAddTerm(x: Triple, mult: number, add: number): Triple {
if (x.a > 0xffff && mult !== 0) {
return {
a: (imul(sdiv(x.a, 1024), mult) + add) | 0,
b: (imul(sdiv(x.b, 1024), mult) + add) | 0,
c: (imul(sdiv(x.c, 1024), mult) + add) | 0,
}
}
return {
a: (sdiv(imul(mult, x.a), 1024) + add) | 0,
b: (sdiv(imul(mult, x.b), 1024) + add) | 0,
c: (sdiv(imul(mult, x.c), 1024) + add) | 0,
}
}
function addInto(acc: Triple, t: Triple): void {
acc.a = (acc.a + t.a) | 0
acc.b = (acc.b + t.b) | 0
acc.c = (acc.c + t.c) | 0
}
/** `ITEMS_CalculateAdditionalCostsForItemSkill` at `0x6fd75970` (StaffMods, stat 107). */
function applyStaffMods(tables: TransactionCostTables, item: TransactionCostInput, rec: ItemsTxtCostRecord, x: Triple, divisor: number): void {
if (!primaryType(tables, rec).staffMods) return
let accA = 0
let accB = 0
let accC = 0
for (const s of item.stats) {
if (s.statId !== STAT_ITEM_SINGLESKILL) continue
const skill = requireSkill(tables, s.param)
const mult = skill.costMult
const add = skill.costAdd
const f = (2 * s.value - 1) | 0
if (x.a > 0xffff && mult !== 0) {
// (sic) the mult is pre-divided here, unlike the other guards.
accA = (accA + imul((imul(sdiv(mult, 1024), x.a) + add) | 0, f)) | 0
accB = (accB + imul((imul(sdiv(mult, 4096), x.b) + add) | 0, f)) | 0
accC = (accC + imul((imul(x.c, sdiv(mult, 1024)) + add) | 0, f)) | 0
} else {
accA = (accA + imul((sdiv(imul(mult, x.a), 1024) + add) | 0, f)) | 0
accB = (accB + imul((sdiv(imul(x.b, mult), 4096) + add) | 0, f)) | 0
accC = (accC + imul((sdiv(imul(x.c, mult), 1024) + add) | 0, f)) | 0
}
}
x.a = (x.a + udiv(accA, divisor, '0x6fd75970')) | 0
x.b = (x.b + udiv(accB, divisor, '0x6fd75970')) | 0
x.c = (x.c + udiv(accC, divisor, '0x6fd75970')) | 0
}
/** `ITEMS_CalculateAdditionalCostsForBonusStats` at `0x6fd78850`. */
function applyBonusStats(tables: TransactionCostTables, item: TransactionCostInput, x: Triple, divisor: number): void {
let accA = 0
let accB = 0
let accC = 0
const accumulate = (mult: number, add: number, v: number, bDiv: 1024 | 4096): void => {
const av = imul(x.a, v)
if (av > 0xffff && mult !== 0) {
accA = (accA + imul(sdiv(av, 1024), mult) + add) | 0
accB = (accB + imul(sdiv(imul(x.b, v), bDiv), mult) + add) | 0
accC = (accC + imul(sdiv(imul(x.c, v), 1024), mult) + add) | 0
} else {
accA = (accA + sdiv(imul(imul(mult, x.a), v), 1024) + add) | 0
accB = (accB + sdiv(imul(imul(x.b, mult), v), bDiv) + add) | 0
accC = (accC + sdiv(imul(imul(x.c, mult), v), 1024) + add) | 0
}
}
let visited = 0
for (const s of item.stats) {
if (visited++ >= MAX_STACK_CAP) break // the stat array walk is bounded at 511 entries
let bonus = (s.value - s.base) | 0
if (bonus === 0) continue
const isc = requireIsc(tables, s.statId)
bonus >>= isc.valShift
switch (isc.encode) {
case 1: {
const skill = requireSkill(tables, s.param)
accumulate(skill.costMult, skill.costAdd, bonus, 4096)
break
}
case 2:
case 3: {
const level = s.param & tables.skillParamMask
const skill = requireSkill(tables, s.param >> tables.skillParamShift)
accumulate(skill.costMult, skill.costAdd, level, 4096)
break
}
case 4: {
const min = ((bonus >> 2) & 0x3ff) - 0x100
const max = ((bonus >> 12) & 0x3ff) - 0x100
accumulate(isc.multiply, isc.add, sdiv(min + max, 2), 1024)
break
}
default:
accumulate(isc.multiply, isc.add, bonus, 1024)
break
}
}
x.a = (x.a + udiv(accA, divisor, '0x6fd78850')) | 0
x.b = (x.b + udiv(accB, divisor, '0x6fd78850')) | 0
x.c = (x.c + udiv(accC, divisor, '0x6fd78850')) | 0
}
/** Recharge cost of depleted charged skills (stat 204), at `0x6fd75810`. */
function rechargeCost(tables: TransactionCostTables, item: TransactionCostInput): number {
let acc = 0
for (const s of item.stats) {
if (s.statId !== STAT_ITEM_CHARGED_SKILL) continue
const level = s.param & tables.skillParamMask
const skill = requireSkill(tables, s.param >> tables.skillParamShift)
const charges = s.value & 0xff
const maxCharges = s.value >> 8
if (charges >= maxCharges) continue
if (skill.reqLevel === undefined) {
throw new Error(`transaction-cost: skill ${skill.id} has no reqlevel; cannot price recharge`)
}
const l = (level + 2 * idiv(skill.reqLevel, 6, '0x6fd75810') + 2) | 0
const x = imul(l, 10000)
let t = x > 0xffff && skill.costMult !== 0
? imul(sdiv(x, 1024), skill.costMult)
: sdiv(imul(imul(skill.costMult, l), 10000), 1024)
t = (t + skill.costAdd) | 0
acc = (acc + idiv(imul(maxCharges - charges, t), maxCharges, '0x6fd75810')) | 0
}
return acc
}
/**
* Gamble price of an item code at a character level (`0x6fd74840`).
* The record used is `normcode` (falling back to the item's own code, as `0x6fd72ff0` does).
*/
export function computeGambleBaseCost113c(code: string, playerLevel: number, tables: TransactionCostTables): number {
const own = requireItem(tables, code)
const rec = own.normCode ? requireItem(tables, own.normCode) : own
if (rec.code === 'rin' || rec.code === 'amu') return rec.gambleCost // 0x6fd748b7 / 0x6fd74a40
let avgStack = sdiv((rec.minStack + rec.maxStack) | 0, 2)
if (avgStack < 1) avgStack = 1
const base = imul(rec.cost, avgStack)
let uberW = 0
let uberCost = 0
// 0x6fd7490c: a 0 field or "0 " is skipped. A code that does not resolve throws here (the asm skips it).
if (rec.uberCode && rec.uberCode !== '0') {
const uber = requireItem(tables, rec.uberCode)
uberW = (sdiv(imul(playerLevel - uber.level, 100), 2) + 1) | 0
if (uberW < 0) uberW = 0
uberCost = uber.cost
}
let ultraW = 0
let ultraCost = 0
if (rec.ultraCode && rec.ultraCode !== '0') {
// 0x6fd7494d
const ultra = requireItem(tables, rec.ultraCode)
ultraW = (sdiv(imul(playerLevel - ultra.level, 100), 4) + 1) | 0
if (ultraW < 0) ultraW = 0
ultraCost = ultra.cost
}
// 0x6fd74998
const mixed = (imul((10000 - ultraW - uberW) | 0, base) + imul(ultraCost, ultraW) + imul(uberCost, uberW)) | 0
const s = idiv(mixed, 10000, '0x6fd749b8')
const l = playerLevel > 5 ? playerLevel : 5 // 0x6fd749c9
const qlvl = rec.level
const over = qlvl - 45 < 0 ? 0 : qlvl - 45
const t = idiv(imul((over - sdiv(qlvl, 2) + l) | 0, 250), 3, '0x6fd749f6')
const f = (idiv((2 * l + 1) | 0, 3, '0x6fd74a0a') + 20) | 0
return idiv(imul((t + s) | 0, f), 15, '0x6fd74a20')
}
// ---------------------------------------------------------------------------
// #10107
// ---------------------------------------------------------------------------
function requireNpc(tables: TransactionCostTables, npcClass: number): NpcCostRecord {
const npc = tables.npcs.get(npcClass)
if (!npc) throw new Error(`transaction-cost: no Npc.txt record for npc class ${npcClass}`) // fatal 0xcdd
return npc
}
/** Npc multiplier with the asm's guard (0x6fd799d8). */
function npcScale(value: number, guardValue: number, guardMult: number, mult: number): number {
if (guardValue > 0xffff && guardMult !== 0) return imul(sdiv(value, 1024), mult)
return sdiv(imul(mult, value), 1024)
}
/**
* `ITEMS_GetTransactionCost` (D2Common #10107, body at 0x6fd78e80).
*
* @param item - the item being priced.
* @param ctx - player, NPC and table state.
* @param type - 0 buy, 1 sell, 2 gamble, 3 repair.
* @returns the gold amount.
*/
export function computeTransactionCost113c(
item: TransactionCostInput,
ctx: TransactionCostContext,
type: TransactionType113c,
): number {
const { tables } = ctx
const rec = requireItem(tables, item.code)
const quantityStat = fullStat(item, STAT_QUANTITY)
const q = quantityStat <= 0 ? 1 : quantityStat
let rp = ctx.playerReducedPricesPct | 0
if (rp >= REDUCED_PRICES_CAP) rp = REDUCED_PRICES_CAP
const clvl = ctx.playerLevel | 0
// 0x6fd78eb4
if (type === 3 && !isItemRepairable113c(item, tables)) return 0
// 0x6fd78ecd
if (item.starterItem) return 1
// 0x6fd78f38: gamble
if (type === 2) {
if ((item.itemFormat & 0xffff) >= 1) {
let cost = computeGambleBaseCost113c(item.code, clvl, tables)
if (rp !== 0) cost = (cost - fogMulDiv113c(rp, cost, 100)) | 0
return cost // no clamp here
}
const own = rec.normCode ? requireItem(tables, rec.normCode) : rec
return own.gambleCost
}
const x: Triple = { a: 0, b: 0, c: 0 }
let divisor = 1
// Base costs, first match wins.
if (item.isEar) {
// 0x6fd78fb0
const lvl = requireDefined(item.earLevel, 'earLevel') & 0xff
x.a = x.b = imul(lvl, rec.cost)
} else if (isItemOfType(tables, rec, 'body')) {
// 0x6fd78fc9
const cls = requireDefined(item.bodyPartMonsterClass, 'bodyPartMonsterClass')
const levels = tables.monsterLevels[cls]
if (!levels) throw new Error(`transaction-cost: no MonStats record ${cls}`)
x.a = x.b = (rec.cost + (levels[ctx.difficulty] << 3)) | 0
} else if (isItemOfType(tables, rec, 'book')) {
// 0x6fd7913e
const bookId = requireDefined(item.bookId, 'bookId')
const book = tables.books[bookId]
if (!book) throw new Error(`transaction-cost: no Books.txt record ${bookId}`) // fatal 0xbbb
x.a = x.b = (imul(book.costPerCharge, q) + rec.cost) | 0
} else if (primaryType(tables, rec).quiver) {
// 0x6fd7917a
x.a = x.b = sdiv(imul(rec.cost, q), 1024)
x.c = sdiv(imul(maxStack(item, rec), rec.cost), 1024)
} else if (rec.stackable) {
// 0x6fd791c5
x.a = x.b = x.c = rec.cost
const ms = maxStack(item, rec)
divisor = ms > 1 ? ms : 1
} else {
x.a = x.b = x.c = rec.cost
}
// 0x6fd7900b: armor scales by base defense over max defense
if (isItemOfType(tables, rec, 'armo')) {
if (((rec.maxAc - rec.minAc + 1) | 0) !== 0 && rec.maxAc !== 0) {
const v = idiv(imul(rec.cost, baseStat(item, STAT_ARMORCLASS)), rec.maxAc, '0x6fd79044')
x.a = x.b = x.c = v
}
}
// 0x6fd74210: magic, set, rare, unique, crafted, tempered
const magicish = item.quality >= 4 && item.quality <= 9
if (!magicish) applyStaffMods(tables, item, rec, x, divisor) // 0x6fd7909c
if (item.identified) {
// 0x6fd790b8: automagic
const n: Triple = { a: 0, b: 0, c: 0 }
const auto = affixRecord(tables.autoMagic, item.autoAffixId ?? 0, 'automagic')
if (auto) addInto(n, multAddTerm(x, auto.multiply, auto.add))
const magicPath = (): void => {
// 0x6fd7954f
const p = affixRecord(tables.magicPrefixes, requireDefined(item.magicPrefixId, 'magicPrefixId'), 'magic prefix')
if (p) addInto(n, multAddTerm(x, p.multiply, p.add))
const s = affixRecord(tables.magicSuffixes, requireDefined(item.magicSuffixId, 'magicSuffixId'), 'magic suffix')
if (s) addInto(n, multAddTerm(x, s.multiply, s.add))
applyBonusStats(tables, item, x, divisor)
}
switch (item.quality) {
case 1: // 0x6fd797fc: low quality replaces the automagic term (sic)
n.a = -sdiv(x.a, 2)
n.b = -sdiv(x.b, 2)
n.c = -sdiv(x.c, 2)
break
case 2:
break
case 3: // 0x6fd79823
case 9:
applyBonusStats(tables, item, x, divisor)
break
case 4:
magicPath()
break
case 5: {
// 0x6fd79720: a missing record is skipped; no bonus stats
const id = requireDefined(item.setItemId, 'setItemId')
const set = id >= 0 ? tables.setItems[id] : undefined
if (set) addInto(n, multAddTerm(x, set.multiply, set.add))
break
}
case 6: // 0x6fd79269
case 8: {
const pre = requireDefined(item.rarePrefixIds, 'rarePrefixIds')
const suf = requireDefined(item.rareSuffixIds, 'rareSuffixIds')
for (let i = 0; i < 3; i++) {
const p = affixRecord(tables.magicPrefixes, pre[i]!, 'rare prefix')
if (p) addInto(n, multAddTerm(x, p.multiply, p.add))
const s = affixRecord(tables.magicSuffixes, suf[i]!, 'rare suffix')
if (s) addInto(n, multAddTerm(x, s.multiply, s.add))
}
applyBonusStats(tables, item, x, divisor)
break
}
case 7: {
// 0x6fd79458: an invalid unique id falls back to the magic path
const id = requireDefined(item.uniqueItemId, 'uniqueItemId')
const uniq = id >= 0 ? tables.uniqueItems[id] : undefined
if (uniq) addInto(n, multAddTerm(x, uniq.multiply, uniq.add))
else magicPath()
break
}
}
// 0x6fd79844: signed division of the terms by the divisor
x.a = (x.a + idiv(n.a, divisor, '0x6fd79844')) | 0
x.b = (x.b + idiv(n.b, divisor, '0x6fd79844')) | 0
x.c = (x.c + idiv(n.c, divisor, '0x6fd79844')) | 0
if (magicish) applyStaffMods(tables, item, rec, x, divisor)
}
// 0x6fd74a50: sockets, counted even when unidentified
for (const childCode of item.socketedChildCodes) {
const half = sdiv(requireItem(tables, childCode).cost, 2)
x.a = (x.a + half) | 0
x.b = (x.b + half) | 0
x.c = (x.c + half) | 0
}
// 0x6fd798b0: quarter the player-sell price
let ebp = x.b
if (item.ethereal) ebp = sdiv(ebp, 4)
if (primaryType(tables, rec).classSpecific) ebp = sdiv(ebp, 4)
// 0x6fd798f5: per-type adjustment
let edi = x.c
if (type === 1) {
if (item.ethereal && hasDurability(item, rec) && fullStat(item, STAT_DURABILITY) <= 0) ebp = 0
} else if (type === 3) {
const t = primaryType(tables, rec)
if (!t.quiver && !t.throwable && hasDurability(item, rec)) {
const cur = fullStat(item, STAT_DURABILITY)
const max = maxDurability(item)
if (max !== 0 && cur < max) {
if (fullStat(item, STAT_ITEM_REPLENISH_DURABILITY) !== 0) {
// (sic) self-repairing items are charged as if one point were missing, or nothing
edi = cur < max - 1 ? idiv(imul(max - 1, x.c), max, '0x6fd7995e') : 0
} else {
edi = idiv(imul(max - cur, x.c), max, '0x6fd79988')
}
} else {
edi = 0
}
}
}
// 0x6fd799d8: Npc.txt multipliers
const npc = requireNpc(tables, ctx.npcClass)
let a = npcScale(x.a, x.a, npc.sellMult, npc.sellMult)
ebp = npcScale(ebp, ebp, npc.sellMult, npc.buyMult) // (sic) guard tests sellMult
edi = npcScale(edi, edi, npc.repMult, npc.repMult)
// 0x6fd79aa0: quest discounts
for (let i = 0; i < 3; i++) {
const flag = npc.questFlags[i]!
if (flag === 0) continue
if (!ctx.isQuestFlagSet(flag, 0) && !ctx.isQuestFlagSet(flag, 1)) continue
a = npcScale(a, a, npc.questSellMults[i]!, npc.questSellMults[i]!)
ebp = npcScale(ebp, ebp, npc.questBuyMults[i]!, npc.questBuyMults[i]!)
edi = npcScale(edi, edi, npc.questRepMults[i]!, npc.questRepMults[i]!)
}
// 0x6fd79b8d: quantity
if (!isItemOfType(tables, rec, 'book') && !primaryType(tables, rec).quiver) {
a = imul(a, q)
if (rec.stackable && isItemRepairable113c(item, tables)) {
const max = maxStack(item, rec)
if (q >= max || fullStat(item, STAT_ITEM_REPLENISH_QUANTITY) !== 0) {
ebp = imul(max, ebp)
edi = 0
} else {
ebp = (imul(max, ebp) - imul(edi, max - q)) | 0
edi = imul(edi, max - q)
}
} else {
ebp = imul(ebp, q)
}
}
// 0x6fd79c0d: recharge, added after the Npc multipliers
if (type === 3 && !item.ethereal) edi = (edi + rechargeCost(tables, item)) | 0
// 0x6fd79c2c: cap the player-sell price
const cap = npc.maxBuy[ctx.difficulty]
if (ebp > cap) ebp = cap
if (type === 1) return ebp < 1 ? 1 : ebp
if (type === 3) {
if (rp !== 0) edi = (edi - fogMulDiv113c(rp, edi, 100)) | 0
return edi < 1 ? 1 : edi
}
if (rp !== 0) a = (a - fogMulDiv113c(rp, a, 100)) | 0
return a < 1 ? 1 : a
}
// ---------------------------------------------------------------------------
// #10071
// ---------------------------------------------------------------------------
/**
* Repair-all cost (D2Common #10071 at 0x6fd7a210).
*
* @param equipped - the 13 body locations (0..12, both weapon sets); `null` for an empty slot.
* @param ctx - same context as a single repair.
* @returns the total gold. Items that need nothing contribute 0.
*/
export function computeRepairAllCost113c(
equipped: readonly (TransactionCostInput | null)[],
ctx: TransactionCostContext,
): number {
if (equipped.length !== REPAIR_ALL_SLOT_COUNT) {
throw new Error(`transaction-cost: repair-all expects ${REPAIR_ALL_SLOT_COUNT} slots, got ${equipped.length}`)
}
const { tables } = ctx
let total = 0
for (const item of equipped) {
if (!item) continue
const rec = requireItem(tables, item.code)
const maxDur = maxDurability(item)
const needs =
(isItemRepairable113c(item, tables) &&
hasDurability(item, rec) &&
maxDur !== 0 &&
fullStat(item, STAT_DURABILITY) !== maxDur) ||
(isRefillableThrowing(tables, item, rec) && fullStat(item, STAT_QUANTITY) < maxStack(item, rec)) ||
hasMissingCharges(item)
if (!needs) continue
total = (total + computeTransactionCost113c(item, ctx, 3)) | 0
}
return total
}
// ---------------------------------------------------------------------------
// Table building
// ---------------------------------------------------------------------------
function column(t: D2Table, name: string, what: string): number {
const i = t.header.indexOf(name)
if (i < 0) throw new Error(`transaction-cost: ${what} has no column '${name}'`)
return i
}
/** Integer txt field. An empty cell compiles to 0, as the txt compiler does. */
function intField(t: D2Table, row: readonly string[], name: string, what: string): number {
const raw = (row[column(t, name, what)] ?? '').trim()
if (raw === '') return 0
if (!/^-?\d+$/.test(raw)) throw new Error(`transaction-cost: ${what}.${name} is not an integer: '${raw}'`)
return Number(raw) | 0
}
function strField(t: D2Table, row: readonly string[], name: string, what: string): string {
return (row[column(t, name, what)] ?? '').trim()
}
/** Compiled rows: the txt compiler drops rows whose first column is "Expansion". */
function compiledRows(t: D2Table): readonly (readonly string[])[] {
return t.rows.filter(r => (r[0] ?? '').trim() !== 'Expansion')
}
function multAddRows(t: D2Table, nameCol: string, multCol: string, addCol: string, what: string): CostMultAdd[] {
return compiledRows(t).map(r => ({
name: strField(t, r, nameCol, what),
multiply: intField(t, r, multCol, what),
add: intField(t, r, addCol, what),
}))
}
/** Which optional columns a items table has. Misc.txt has no durability, AC or tier columns; they compile to 0. */
interface ItemsTableShape {
readonly hasAc: boolean
readonly hasTiers: boolean
readonly hasDurability: boolean
}
function addItems(out: Map<string, ItemsTxtCostRecord>, t: D2Table, what: string, shape: ItemsTableShape): void {
const { hasAc, hasTiers } = shape
for (const r of compiledRows(t)) {
const code = strField(t, r, 'code', what)
if (!code) continue
if (out.has(code)) throw new Error(`transaction-cost: duplicate ItemsTxt code '${code}'`)
const tier = (name: string): string => {
if (!hasTiers) return ''
const v = strField(t, r, name, what)
return v === '0' ? '' : v
}
out.set(code, {
code,
type: strField(t, r, 'type', what),
type2: strField(t, r, 'type2', what),
cost: intField(t, r, 'cost', what),
gambleCost: intField(t, r, 'gamble cost', what),
minStack: intField(t, r, 'minstack', what),
maxStack: intField(t, r, 'maxstack', what),
stackable: intField(t, r, 'stackable', what) !== 0,
durability: shape.hasDurability ? intField(t, r, 'durability', what) & 0xff : 0,
noDurability: intField(t, r, 'nodurability', what) !== 0,
level: intField(t, r, 'level', what) & 0xff,
normCode: tier('normcode'),
uberCode: tier('ubercode'),
ultraCode: tier('ultracode'),
minAc: hasAc ? intField(t, r, 'minac', what) : 0,
maxAc: hasAc ? intField(t, r, 'maxac', what) : 0,
})
}
}
/** Build the compiled tables from raw txt tables. */
export function buildTransactionCostTables(raw: TransactionCostRawTables): TransactionCostTables {
const items = new Map<string, ItemsTxtCostRecord>()
addItems(items, raw.weapons, 'Weapons', { hasAc: false, hasTiers: true, hasDurability: true })
addItems(items, raw.armor, 'Armor', { hasAc: true, hasTiers: true, hasDurability: true })
addItems(items, raw.misc, 'Misc', { hasAc: false, hasTiers: false, hasDurability: false })
const itemTypes = new Map<string, ItemTypeCostRecord>()
for (const r of compiledRows(raw.itemTypes)) {
const w = 'ItemTypes'
const code = strField(raw.itemTypes, r, 'Code', w)
if (!code) continue
itemTypes.set(code, {
code,
equiv1: strField(raw.itemTypes, r, 'Equiv1', w),
equiv2: strField(raw.itemTypes, r, 'Equiv2', w),
repair: intField(raw.itemTypes, r, 'Repair', w) !== 0,
quiver: strField(raw.itemTypes, r, 'Quiver', w) !== '',
throwable: intField(raw.itemTypes, r, 'Throwable', w) !== 0,
staffMods: strField(raw.itemTypes, r, 'StaffMods', w) !== '',
classSpecific: strField(raw.itemTypes, r, 'Class', w) !== '',
})
}
const itemStatCost = new Map<number, ItemStatCostRecord>()
for (const r of compiledRows(raw.itemStatCost)) {
const w = 'ItemStatCost'
if (strField(raw.itemStatCost, r, 'ID', w) === '') continue
const id = intField(raw.itemStatCost, r, 'ID', w)
itemStatCost.set(id, {
id,
encode: intField(raw.itemStatCost, r, 'Encode', w),
add: intField(raw.itemStatCost, r, 'Add', w),
multiply: intField(raw.itemStatCost, r, 'Multiply', w),
valShift: intField(raw.itemStatCost, r, 'ValShift', w),
})
}
const skills = new Map<number, SkillCostRecord>()
for (const s of raw.skills) skills.set(s.id, s)
const books = compiledRows(raw.books).map(r => ({
name: strField(raw.books, r, 'Name', 'Books'),
baseCost: intField(raw.books, r, 'BaseCost', 'Books'),
costPerCharge: intField(raw.books, r, 'CostPerCharge', 'Books'),
}))
const monRows = compiledRows(raw.monStats)
const npcClassByName = new Map<string, number>()
const monsterLevels = monRows.map((r, i) => {
const id = strField(raw.monStats, r, 'Id', 'MonStats').toLowerCase()
if (id && !npcClassByName.has(id)) npcClassByName.set(id, i)
return [
intField(raw.monStats, r, 'Level', 'MonStats'),
intField(raw.monStats, r, 'Level(N)', 'MonStats'),
intField(raw.monStats, r, 'Level(H)', 'MonStats'),
] as const
})
const npcs = new Map<number, NpcCostRecord>()
const npcNames = new Map<string, number>()
for (const r of compiledRows(raw.npc)) {
const t = raw.npc
const w = 'Npc'
const name = strField(t, r, 'npc', w)
if (!name) continue
const npcClass = npcClassByName.get(name.toLowerCase())
if (npcClass === undefined) throw new Error(`transaction-cost: Npc.txt '${name}' has no MonStats row`)
const abc = (prefix: string): [number, number, number] => [
intField(t, r, `${prefix} A`, w),
intField(t, r, `${prefix} B`, w),
intField(t, r, `${prefix} C`, w),
]
npcs.set(npcClass, {
name,
npcClass,
sellMult: intField(t, r, 'sell mult', w),
buyMult: intField(t, r, 'buy mult', w),
repMult: intField(t, r, 'rep mult', w),
questFlags: abc('questflag'),
questSellMults: abc('questsellmult'),
questBuyMults: abc('questbuymult'),
questRepMults: abc('questrepmult'),
maxBuy: [intField(t, r, 'max buy', w), intField(t, r, 'max buy (N)', w), intField(t, r, 'max buy (H)', w)],
})
npcNames.set(name.toLowerCase(), npcClass)
}
return {
items,
itemTypes,
magicPrefixes: multAddRows(raw.magicPrefix, 'Name', 'multiply', 'add', 'MagicPrefix'),
magicSuffixes: multAddRows(raw.magicSuffix, 'Name', 'multiply', 'add', 'MagicSuffix'),
autoMagic: multAddRows(raw.autoMagic, 'Name', 'multiply', 'add', 'AutoMagic'),
uniqueItems: multAddRows(raw.uniqueItems, 'index', 'cost mult', 'cost add', 'UniqueItems'),
setItems: multAddRows(raw.setItems, 'index', 'cost mult', 'cost add', 'SetItems'),
itemStatCost,
skills,
books,
npcs,
npcClassByName: npcNames,
monsterLevels,
// UNCONFIRMED: the writer of DataTables +0xc6c/+0xc70 was not located; 6/0x3f is the
// split the old-format conversion at 0x6fd7bd2c uses.
skillParamShift: 6,
skillParamMask: 0x3f,
}
}
let canonicalTables: TransactionCostTables | undefined
/** Tables built from the embedded canonical 1.13c data. Built once, on first call. */
export function getCanonicalTransactionCostTables(): TransactionCostTables {
if (canonicalTables) return canonicalTables
const parse = (text: string): D2Table => parseTable(new TextEncoder().encode(text))
const skills: SkillCostRecord[] = CANONICAL_SKILL_COSTS.map(s => ({
id: s.id,
costMult: s.costMult,
costAdd: s.costAdd,
reqLevel: SKILLS_BY_ID[s.id]?.reqlevel,
}))
canonicalTables = buildTransactionCostTables({
weapons: parse(RAW_WEAPONS),
armor: parse(RAW_ARMOR),
misc: parse(RAW_MISC),
itemTypes: parse(RAW_ITEM_TYPES),
magicPrefix: parse(RAW_MAGIC_PREFIX),
magicSuffix: parse(RAW_MAGIC_SUFFIX),
autoMagic: parse(CANONICAL_AUTOMAGIC_TSV.trim()),
uniqueItems: parse(RAW_UNIQUE_ITEMS),
setItems: parse(RAW_SET_ITEMS),
itemStatCost: parse(RAW_ITEM_STAT_COST),
books: parse(RAW_BOOKS),
npc: parse(RAW_NPC),
monStats: parse(RAW_MONSTATS),
skills,
})
return canonicalTables
}
/** Look up an NPC's class (MonStats hcIdx) by its Npc.txt name; throws if unknown. */
export function getNpcClassByName113c(tables: TransactionCostTables, name: string): number {
const cls = tables.npcClassByName.get(name.toLowerCase())
if (cls === undefined) throw new Error(`transaction-cost: unknown Npc.txt npc '${name}'`)
return cls
}