Challenger Continent · Item Price Bench

物品价格换算台The Counting House

DAT 里的 <INTEGER>VALUE: 不是商人窗口里那个数。引擎拿它乘一条按物品等级走的百分比曲线,再 ceil 成整数 —— 买入、卖出、未鉴定买、未鉴定卖各走各的曲线,六个定价档各有各的一套。这里按 Torchlight2.exe 1.25.9.5 逐指令复算,也能反过来从目标价推回该写多少 VALUE。

Ⅰ换算台The Bench

左边填 DAT 里的键和现场条件,右边是引擎会算出来的金币数。所有数字实时联动。

物品档案 the item

VALUE · DAT 整数键
LEVEL · 物品等级
定价档 · 引擎判出来的,不是 DAT 里选的
堆叠数量 · 价格是整堆的
定价档是引擎按先后顺序命中的,不是你在 DAT 里选的: 赌博存货 → Unique → Magic → 带词缀 → Map → 普通。UNITTYPE 同时含 Unique 和 Magic 时走 Unique。

商人与角色 at the counter

插槽数 · 装备上的孔位数
商人加价 · 商人 DAT 的 MERCHANT_PRICE_MULT
每孔加成% · GLOBALS 的 SOCKET_VALUE_BONUS_PCT
宝石折价% · GLOBALS 的 SOCKETED_SELL_PCT
角色物品价值% · 引擎内置 stat 83
MAP 运行期系数 · *(float*)(this+0x4A8)
后四项默认就是原版 MEDIA/GLOBALS.DAT 与商人 DAT 的值(挑战者大陆没改)。 stat 83 是角色侧的隐藏统计:卖出时加成、买入时打折,原版里没有任何词缀会动它,留 0 即可。

已镶嵌的宝石 gems in the sockets

填每颗宝石自己的卖出价(把它单独填进上面的换算台就能得到)。
引擎只把宝石价值算进卖价,按 SOCKETED_SELL_PCT; 买价完全不看已镶嵌的宝石。插槽数本身两边都算。

商人窗口会写什么 the readout

价格随等级怎么走 by item level

买入价 卖出价 当前等级
致命 · 定价会失效 警告 · 结果与直觉不符 提示 · 机制说明 良好
◆ ◆ ◆

Ⅱ从目标价反推 VALUEWorking Backwards

定价时真正要回答的问题是「我要玩家花多少金」。填目标金额,这里给出该写的 VALUE。 等级、定价档、堆叠、商人与角色修正全部沿用上面换算台的设置。

目标 the goal

目标是哪个价
总共要花 / 收 · 金币
分几次买 / 卖 · 份数
「份数」是给消耗品用的:升满一件装备要 10 个,总预算 45000 金,那就填 45000 / 10 —— 单份目标价自动算成 4500。 只定单件价格就把份数留 1。堆叠数量另算:那是一格里叠了几个,会整体乘上去。

该写多少 the answer

◆ ◆ ◆

Ⅲ批量对照Side by Side

一次算一批,方便把一整条消耗品线 / 一套装备的定价摆在一起看。

输入 one item per line

每行 名称, VALUE, 等级, 定价档,后三项可省略(省略就沿用换算台的设置)。 第一段不是数字就当成名称。定价档写 NORMAL / GREEN / MAGIC / UNIQUE / MAP / GAMBLE, 也认 普通 / 词缀 / 魔法 / 唯一 / 地图 / 赌博。行首 # 是注释。

结果 the ledger

◆ ◆ ◆

Ⅳ公式与出处Chapter & Verse

下面每一行都能在 Torchlight2.exe 1.25.9.5(基址 0x400000)里指到具体地址。 没确认的部分单列在最后,没有猜的成分。

第一步 —— CEquipment::computePrices @0x581AF0。物品创建 / 读档时跑一次, 把四个整数缓存进对象:+0x2A8 买入、+0x2AC 卖出、+0x2B0 未鉴定买、+0x2B4 未鉴定卖。

V = getInt(dat, "VALUE", 100)        @0x581B3D  键不存在时默认 100,不是 0
if (V == 0) { 买=1; 卖=1; return; }    @0x581B6C  cmp ebx,edi(edi=0) —— 整数比较
L = max(itemLevel, 1)                @0x581B74  itemLevel = CBaseUnit+0x110,DAT 的 LEVEL 或刷怪时 setLevel

档位按这个顺序判,先命中先算:
  0. isGamblerStock            @0x57BFC0  → PRICE_PLAYERGAMBLE_MAGIC( L + max(0, rand(-3..3)) ),卖价保持 1
  1. UNITTYPE ⊃ Unique (0x36)  @0x511FA0  → PRICE_PLAYERBUY_UNIQUE / PRICE_PLAYERSELL_UNIQUE
  2. UNITTYPE ⊃ Magic  (0x37)            → PRICE_PLAYERBUY_MAGIC  / PRICE_PLAYERSELL_MAGIC
  3. vtable+0x1C0 为真          @0x57EB60  → PRICE_PLAYERBUY_GREEN  / PRICE_PLAYERSELL_GREEN
  4. UNITTYPE ⊃ Map    (0xAC)            → MAGIC 两条曲线,且先 V = (int)(*(float*)(this+0x4A8) × V)
  5. 其它                                → PRICE_PLAYERBUY_NORMAL / PRICE_PLAYERSELL_NORMAL

买 = (int)ceil( V × 曲线(L) × 0.01f )     0.01f = flt_2142654 = 0.00999999977648258…
卖 = (int)ceil( V × 曲线(L) × 0.01f )

无论走哪一档,最后都会再跑一遍 NORMAL 两条曲线,填未鉴定价:  @0x582254
未鉴定买 = (int)ceil( f32(V) × PRICE_PLAYERBUY_NORMAL(L)  × 0.01f )
未鉴定卖 = (int)ceil( f32(V) × PRICE_PLAYERSELL_NORMAL(L) × 0.01f )

第二步 —— 商人窗口现算,每次都跑: CItem::getPlayerBuyPrice @0x57E070 与 CItem::getPlayerSellValue @0x577C10。

买入价  if (!已鉴定 +0x3BC==0) return max(堆叠,1) × 未鉴定买
        p = max(堆叠,1) × 买                       堆叠 = +0x278
        if (在赌博容器里) return 卖出价(this)        @0x57E0EA  赌博窗口用卖价当买价
        p = (int)( getFloat(商人dat, "MERCHANT_PRICE_MULT", 1.0) × p )
        if (插槽数) p = (int)( (插槽数 × SOCKET_VALUE_BONUS_PCT × 0.01 + 1) × p )
        if (stat83) p = max( p − (int)(stat83 × 0.01 × p), 1 )   ← 减

卖出价  if (!已鉴定) return max(堆叠,1) × 未鉴定卖
        p = max(堆叠,1) × 卖
        if (stat83) p += (int)(stat83 × 0.01 × p)                ← 加
        if (插槽数) p = (int)( (插槽数 × SOCKET_VALUE_BONUS_PCT × 0.01 + 1) × p )
        for each 已镶嵌且 UNITTYPE ⊃ Socketable(0x78):
            p = (int)( 该宝石的卖出价 × SOCKETED_SELL_PCT × 0.01 + p )   递归调用自己

曲线 —— MEDIA/GRAPHS/STATS/PRICE_*.DAT, 每条 DATATYPE:LINE、CURVED:false、105 个控制点(X = 物品等级 1…105)。 十条全都是精确的直线,所以可以写成闭式:

曲线Y(L) = 百分比L=1L=50L=105L>105
PRICE_PLAYERBUY_NORMAL49 + 11 L605991204继续外推
PRICE_PLAYERSELL_NORMAL14 + 1 L1564119继续外推
PRICE_PLAYERBUY_GREEN220.5 + 49.5 L2702695.55418封顶
PRICE_PLAYERSELL_GREEN35 + 2.5 L37.5160297.5封顶
PRICE_PLAYERBUY_MAGIC392 + 88 L48047929632继续外推
PRICE_PLAYERSELL_MAGIC56 + 4 L60256476继续外推
PRICE_PLAYERBUY_UNIQUE2352 + 528 L28802875257792继续外推
PRICE_PLAYERSELL_UNIQUE168 + 12 L1807681428继续外推
PRICE_PLAYERGAMBLE_MAGIC400 + 100 L500540010900继续外推
PRICE_ENCHANT (附魔费,本工具不算)245 + 55 L30029956020继续外推

L 超过 105 时的行为不是统一的,取决于该 DAT 里有没有 INFER_PASSED_END 这个键 (CGraph::evaluate @0x688E00 读 +0x3D,缺省 false —— @0x689599 push 0):

  • 有 INFER_PASSED_END:true十条里的八条都有,超过 105 之后沿最后一段的斜率继续外推, 用的是缓存好的斜率与末点(+0x40 / +0x48 / +0x50)。
  • 两条 GREEN 没有这个键于是走曲线对象自己的求值 DynamicAttributeCurved::getValue @0x7F1090 —— 那里对超出末点的 x 直接返回末点的 Y。 结果:等级 106 以上的「带词缀普通装」按 105 级定价,价格封顶;同一件东西如果是 Magic / Unique 就会继续涨。

两条 ceil 路径不一样,这不是笔误:

NORMAL / GREEN / MAP 档   @0x58221A  fstp dword → ceil(float)  → 先舍到 float32 再 ceil
UNIQUE / MAGIC / GAMBLE   @0x581D3E  fstp qword → ceil(double) → 保持 double 再 ceil
未鉴定的那一对            @0x58232F  fstp qword,且 V 是从 float32 槽 fld 回来的

因为 0.01f 比 1/100 略小,乘出来永远比真值低一点点。走 float32 的那条路, 这点误差会被 float32 的舍入吃掉;走 double 的那条不会。真值不是整数时两条路会差 1 金。 在 VALUE 1…399999 的范围内扫 GREEN 买价(L=105,百分比 5418 带小数),有 148373 个 VALUE 两条路结果不同 —— 本工具把两条路分开实现了,没有统一成一种。

地址一览 · 每个结论对应的函数
地址函数作用
0x581AF0CEquipment::computePricesVALUE × 等级曲线 → 四个缓存价
0x57E070CItem::getPlayerBuyPrice买入价:堆叠 / 商人乘数 / 插槽 / stat83 折扣
0x577C10CItem::getPlayerSellValue卖出价:堆叠 / stat83 加成 / 插槽 / 逐颗宝石
0x688E00CGraph::evaluate+0x3D = INFER_PASSED_END,真则外推
0x7F1090DynamicAttributeCurved::getValue线性插值;超出两端一律夹到端点 Y
0x689490CGraph::loadFromDat读 CURVED / INFER_PASSED_END(默认 false)
0x57EB60CEquipment vtable+0x1C0GREEN 判据:非 Magic、非 Socketable,且有已烘焙附加伤害或任何非隐藏 effect / 词缀
0x511FA0CUnit::hasUnitTypeid 来自 MEDIA/UNITTYPES.HIE:54=Unique 55=Magic 172=Map 120=Socketable 41=Merchant
0x641792 / 0x6417D8CGlobals::loadFromDat+0xF0=SOCKETED_SELL_PCT(75)、+0xF4=SOCKET_VALUE_BONUS_PCT(10)
0x516A74CBaseUnit::loadCommonFromDatDAT 的 LEVEL → +0x110,就是曲线的 X
0x41C8C0(内联包装)ceil(float) —— NORMAL/GREEN/MAP 那条路的舍入点

数据出处:E:/Torchlight 2/MEDIA/GRAPHS/STATS/PRICE_*.DAT(10 个文件,逐点解析后确认全为直线)、 MEDIA/GLOBALS.DAT、MEDIA/UNITTYPES.HIE。 挑战者大陆的 暗黑传奇 与 Adventure_mode 两个组件覆盖了 GLOBALS.DAT,但三个价格相关键与原版一致; 仓库里那套 PRICE_*.DAT 覆盖已经移进「已移除内容」,当前不生效 —— 所以本工具用的就是原版曲线。

没确认的部分 · 别当成已知
  • stat 83 的数据名只知道 CUnit::getStat(unit, 83, 7, 0) 被这两个价格函数调用, 且全镜像只有这两处调它。它是引擎内置的预定义 stat,不在 MEDIA/STATS 里,名字没查出来。 原版没有任何数据会写它,所以实战恒为 0;工具留了输入位,但那一栏填的数没有实测背书。
  • MAP 档的运行期系数*(float*)(this+0x4A8) 在地图物品定价前会乘一次 V, 来源是地图生成流程(@0x582105 附近一串 DUNGEON 相关调用)。这条链没往下追, 工具默认按 1.0 算 —— 地图物品的读数只在系数为 1 时可信。
  • 赌博档的随机项rand(-3, 3) 取正部后加到等级上(@0x581BC1), 每次刷新赌博商人重掷。工具默认按 +0 显示,可在等级里自己加。
  • 没有实测对照本页的公式来自静态反汇编,不是从游戏内售价拟合出来的。 逐指令的读法可以核对(地址都列在上面),但没有拿运行中的游戏做过端到端验证。