NOTE
逆向方法与数据支撑:本文以 32 位客户端 Torchlight2.exe(ImageBase 0x400000,文内一切 sub_XXXXXX 与 0xXXXXXX 均为绝对虚地址)为对象,证据分三档:O0 字节锚点(可直接在镜像里按字节核对)、O2 反编译读出的控制流、O3 引擎自己的键位数据(MEDIA/KEYMAPPING/ 三份 DAT)。全部结论已收敛进真值账本 C-0172(must-exact 级、guarded 状态)。
本文只到「什么时候下单、下给谁」为止。 真正启动移动/攻击的 sub_544200 与 CCharacter::castSelectedSkill 的内部没有逆,伤害管线一行都不在本文范围。凡是材料里标为「没逆 / 按名字推的 / 未实现 / 已知偏差」的,文内一律原样标注,不补全。
核心工程结论:左键在引擎眼里就叫 Move/Attack——数据里写着,汇编里也读得出来,两个独立来源逐条对上。按住不放会每 tick 重新下单,节奏交给被调方;攻击距离先过一道 ±2.5 的高度门再比水平距离,而远近不是角色的常量,是装备的函数。
0. 核心结论速览
- 左键是「移动/攻击」二合一,名字是引擎自己起的:
MEDIA/KEYMAPPING/KEYBIND.DAT的第一条绑定就是NAME:Move/Attack KEY:LMB EXCLUSIVE:true。同一张表里Move(只走不打)是独立按键`。⇒ 「只走」在引擎眼里是需要额外按键的例外,默认路径就是 Move/Attack。 - 两条调用链汇到同一个分叉函数:鼠标消息
sub_43D610→sub_439750(点击派发)→sub_42A4E0;每 tick 的sub_439180(命令处理器)→sub_438080(order 处理器)→ 同一个sub_42A4E0。 sub_42A4E0(controller, a5)每次被调都重新分叉:光标下的单位进controller+488(主目标)/+512(次目标);CCharacter__isHostileTo为真走攻击路(够不着就先靠近再sub_544200,够得着直接sub_544200);没有敌对目标且a5为真 ⇒sub_426DC0()走路。- 主目标不粘:
controller+488每 tick 由悬停拾取刷新,不是点一次就锁住。 - 按住不放会持续下单,证据是
0x43A903起的 20 个字节:cmp byte [ebp+52h],0→jnz +0Dh正好跨过把待处理点击目标dword_287DBF8/DBFC清成 −1 的三条指令(3+5+5=13 字节)→mov byte [ebp+52h],1。第一 tick 清一次并置位,之后每 tick 目标还在就重复下单,直到松手(+0x52在松手路径0x43A69E被清 0)。 - 下单是每 tick 的,节奏由被调方把关:纯移动
sub_426DC0内部有*(float*)(this+56) = 0.1的 0.1 秒节流;攻击的节奏在sub_544200/sub_545070里(攻击延迟 / 动画)。调用方一行冷却都不写。 - 攻击距离
CCharacter::isTargetInAttackRange=sub_5350C0,形状是「先高度门、再水平距离」:|dy| > 2.5 且不是远程⇒ 直接 false;然后reach + rangeBonus >= (d − r目标) − r自己。 - 2.5 是从数据段读出来的:
0x5351FE处fcomp dword ptr [0x216FDF8],0x216FDF8的四个字节00 00 20 40就是小端 float 2.5。 - 远近由装备决定:
CInventory::getItemAtEquipLocation(inv, 1)与(inv, 2)两个武器槽,任一件hasUnitType(0x23)且不是0xBC、也不是0x74⇒ 远程分支;当前技能的+36字节为真也算远程。rangeBonus只在远程时非 0,值是getStat(78, 7, 0)。 - 三份 KEYMAPPING DAT 一共 64 条绑定:KEYBIND 40 + QUICKKEYS 14 + HELPERBINDINGS 10。隐藏 11、独占 48、带修饰键 14、鼠标 5,全部能映射。
- 原版数据自带一条冲突:
CTRL+S绑了两次(SoundDebug/SteamStatsData,都在 HELPERBINDINGS.DAT,都EXCLUSIVE:false、都HIDDEN:true)。谁赢没查。 w是 WeaponSwap、s是 SkillMenu、a是 AutoTarget ⇒ TL2 原生没有 WASD 移动,任何要加 WASD 的 MOD 都得先解决这三条冲突。- TL2 的字符串转 bool 只看首字符:非空且首字符 ∉ {F, f, 0} ⇒ 真。MOD 里写
no/off全都是 true。
1. 左键是 Move/Attack 二合一
1.1 引擎自己给它起了名字
这一条结论有两个互不依赖的来源。汇编那边先读出「移动与攻击走同一个入口」,之后才在数据里看到 KEYBIND.DAT 第一条就叫 Move/Attack。两边逐条对上——这种情况不多见,引擎自己的数据当了第二个 oracle。
KEYBIND.DAT 里与这条口径直接相关的绑定:
| NAME(原始拼写) | DISPLAYNAME | KEY | EXCLUSIVE | HIDDEN | 口径 |
|---|---|---|---|---|---|
Move/Attack | Move/Attack | LMB | true | false | 第一条。移动与攻击二合一,默认路径 |
Move | Move | ` | true | false | 只走不打的独立键 ⇒ 「只走」是需要额外按键的例外 |
CastSKill | Cast Active Skill | RMB | true | false | 释放当前技能。SKill 的大写 K 是数据里的原始拼写 |
HoldPosition | Hold Position | SHIFT | false | false | 站桩打、不追。行为没逆过,只知道名字 |
AttackOnlyTargets | Attack Only Valid Targets | CTRL | true | false | 只打目标、不误触地面。行为没逆过,只知道名字 |
AutoTarget | Auto-Target | a | false | true | 隐藏绑定 |
HoldPosition 与 AttackOnlyTargets 两行的「口径」一列是按 DISPLAYNAME 推的字面意思,引擎里对应的分支没有逆。
1.2 两条调用链,一个分叉点
鼠标消息 sub_43D610 ──► sub_439750(点击派发) ──► sub_42A4E0(移动/攻击分叉)
每 tick sub_439180(命令处理器)──► sub_438080(order 处理器)──► sub_42A4E0(同一个)点击那一刻走上面那条,之后每个 tick 走下面那条,终点都是 sub_42A4E0。这就是为什么「按住」与「点一下」在引擎里是同一个函数在反复跑,而不是两套逻辑。
1.3 sub_42A4E0(controller, a5) 的三步分叉
每次被调都从头分叉,不带上一次的状态:
| 步 | 做什么 | 细节 |
|---|---|---|
| ① 取目标 | 光标下的单位写进 controller+488(主目标)/ controller+512(次目标) | 这两个槽每 tick 由悬停拾取刷新,见 §1.4 |
| ② 有敌对目标 | CCharacter__isHostileTo(目标) 为真 ⇒ 攻击路 | isTargetInAttackRange() 为假 ⇒ setTarget + vt+512(player, 4, 0)(移动模式 4 = 靠近)+ vt+584(转向),随后仍然调 sub_544200(...);为真 ⇒ 直接 sub_544200(...) |
| ③ 没有敌对目标 | 且 a5 为真 ⇒ sub_426DC0() 走路 | sub_426DC0 内部有 0.1 秒节流,见 §2.3 |
a5 是「允不允许走」的开关。点击路径上传的是 1——0x43A8FA 处的 9 个字节 6A 01 8B CD E8 DD FB FE FF 就是 push 1 / mov ecx,ebp / call sub_42A4E0。
sub_544200 是真正「启动移动/攻击」的函数,本文只到调它为止,它内部怎么起动画、怎么算伤害没有逆。
1.4 主目标每 tick 刷新,不粘
controller+488 不是点一次就锁住的。它由悬停拾取管线每 tick 重写,光标挪开了目标就变。⇒ 「按住左键扫过一群怪」在引擎里等价于每 tick 对光标当前指着的那只重新下单。
TIP
这对做 MOD 意味着什么
- 左键的「移动/攻击二合一」是这套操作手感的根。改键位时把它拆成两个动作,改掉的不是一个按键,是分叉函数
sub_42A4E0被调用的前提。 - 想要「只走不打」,数据里已经有一条
Move=`。它是独立绑定,不是 Move/Attack 的修饰态。 HoldPosition(SHIFT)、AttackOnlyTargets(CTRL)、Move(`)三个键只在数据里确认了名字,行为分支没有逆。别拿本文当它们的行为说明。
2. 按住不放会持续下单
2.1 0x43A903 起的 20 个字节
这是「按住持续下单」的全部证据。原始字节:
80 7D 52 00 75 0D 83 C8 FF A3 F8 DB 87 02 A3 FC DB 87 02 C6拆开看:
| 相对偏移 | 字节 | 指令 | 长度 | 作用 |
|---|---|---|---|---|
| +0 | 80 7D 52 00 | cmp byte ptr [ebp+52h], 0 | 4 | 查「已在按住态」标志 |
| +4 | 75 0D | jnz +0Dh | 2 | 标志非零 ⇒ 跳过下面 13 字节 |
| +6 | 83 C8 FF | or eax, -1 | 3 | 准备 −1 |
| +9 | A3 F8 DB 87 02 | mov dword_287DBF8, eax | 5 | 待处理点击目标清成 −1 |
| +14 | A3 FC DB 87 02 | mov dword_287DBFC, eax | 5 | 同上,第二个槽 |
| +19 | C6 … | mov byte ptr [ebp+52h], 1 的首字节 | — | 处理完之后置位 |
jnz +0Dh 的 0x0D = 13 = 3+5+5,正好跨过那三条清 −1 的指令,落在 C6 上。于是:
- 第一 tick:标志为 0,不跳 ⇒ 清一次待处理目标,然后置位。
- 之后每 tick:标志为 1,跳过清除 ⇒ 目标还在,就再下一次单。
- 松手:
+0x52在松手路径0x43A69E被清 0,循环结束。
2.2 它紧接着 call sub_42A4E0
0x43A8FA 的 9 字节 push 1 / mov ecx,ebp / call sub_42A4E0 加 9 正好是 0x43A903。也就是说这 20 字节是 call sub_42A4E0 返回之后的第一件事——每 tick 先分叉一次(§1.3),回来再决定要不要把目标清掉。
2.3 节奏由被调方把关
下单是每 tick 的,但攻击不会每 tick 挥一次。节奏在被调方:
| 分支 | 节奏在哪 | 证据 |
|---|---|---|
纯移动 sub_426DC0 | 函数内部 *(float*)(this+56) = 0.1,0.1 秒的下单节流 | 0x426DC0 序言 16 字节 83 EC 1C 56 8B F1 8D 4C 24 08 FF 15 68 71 11 02 |
| 攻击 | sub_544200 / sub_545070(攻击延迟 / 动画) | 内部没逆,只定位到节奏归它们管 |
调用方 sub_42A4E0 及其上游一行冷却都不写。
TIP
这对做 MOD 意味着什么
- 「按住左键连续攻击」不是 UI 层的自动重复,是每 tick 真的重新走一遍分叉。想改攻击节奏,去看攻击延迟与动画那一侧(
sub_544200/sub_545070),不在输入层。 - 移动那 0.1 秒是写死在
sub_426DC0里的浮点常量,不来自 DAT。
3. 攻击距离:CCharacter::isTargetInAttackRange(sub_5350C0)
3.1 判定形状:先高度门,再水平距离
① 高度门 if (fabs(dy) > 2.5 && !远程) return false; // 先于一切
② 水平距离 d = length(目标位置 − 自己位置, 其中 Y 分量置 0);
③ 判定 return reach + rangeBonus >= (d − r目标) − r自己;顺序是硬的:高度门在水平距离比较之前。一只怪站在比你高 2.5 以上的台子上,近战连水平距离都不比就判「够不着」。
3.2 reach 的构成
reach = sub_52A9D0(player) * *(float*)(player + 1588)
rangeBonus = 远程 ? getStat(78, 7, 0) : 0sub_52A9D0取的是当前选中技能的+108字段,并会被武器抬高;双持时取两把里大的那个。+1588是单位上的一个 float 乘子。rangeBonus只在远程分支非 0,值是 stat 下标 78。
技能的 +108、单位的 +1588、stat 78 各自对应哪个 DAT 键名,本文材料里没有,不猜。
3.3 远近由装备决定
「远程标志」(§3.4 汇编里的 bl)这样算:
| 条件 | 读什么 | 结果 |
|---|---|---|
| 武器槽 1 | CInventory::getItemAtEquipLocation(inv, 1) 的物品 hasUnitType(0x23) 且不是 0xBC、不是 0x74 | 远程 |
| 武器槽 2 | CInventory::getItemAtEquipLocation(inv, 2),同上判定 | 远程 |
| 当前技能 | 技能的 +36 字节为真 | 远程 |
三条任一成立即远程。⇒ 「够不够得着」是装备的函数,不是角色的常量。三个 unittype 下标在材料里只有十六进制值,没有对应到 UNITTYPES 名字。
3.4 高度门的字节,与那个 2.5
0x5351FE 起 18 字节:
D8 1D F8 FD 16 02 DF E0 F6 C4 41 75 08 84 DB 0F 84 D9| 字节 | 指令 | 作用 |
|---|---|---|
D8 1D F8 FD 16 02 | fcomp dword ptr [0x216FDF8] | 与数据段里的常量比较 |
DF E0 | fnstsw ax | 取 x87 状态字 |
F6 C4 41 | test ah, 41h | |
75 08 | jnz +8 | 不超过 2.5 ⇒ 跳过这道门 |
84 DB | test bl, bl | bl = 远程标志(§3.3) |
0F 84 D9 … | jz … | 非远程 ⇒ 返回 0(够不着) |
常量本体在 0x216FDF8,4 字节 00 00 20 40,小端 float = 2.5。这个数是从镜像的数据段读出来的,不是抄的。
TIP
这对做 MOD 意味着什么
- 给武器加/减
0x23、0xBC、0x74这几个 unittype 会直接改攻击距离的分支:走不走远程、吃不吃getStat(78)的加成、受不受高度门管。 - 近战有一条 ±2.5 的高度门,远程没有。放在高低差上的怪,近战够不着而远程够得着——这条可以拿来做地形设计。
reach会被武器抬高、双持取大的那把。改武器就在改 reach,不用碰技能。
4. 键位全表
4.1 三份 DAT 与普查
MEDIA/KEYMAPPING/ 下三份 DAT(UTF-16LE),每条绑定是一个 [KEYBIND] … [/KEYBIND] 块,字段 NAME / DISPLAYNAME / KEY / MODIFIERKEY / EXCLUSIVE / HIDDEN。
| 文件 | 条数 |
|---|---|
KEYBIND.DAT | 40 |
QUICKKEYS.DAT | 14 |
HELPERBINDINGS.DAT | 10 |
| 合计 | 64 |
普查:隐藏 11 · 独占 48 · 带修饰键 14 · 鼠标 5 · 映射不出来的 0。HELPERBINDINGS.DAT 的 10 条全部 HIDDEN:true、全部 EXCLUSIVE:false。
KEY 与 MODIFIERKEY 都是字符串,大小写混用(KEY:F 与 KEY:c 并存)。引擎侧怎么解析这两个串没逆——下面的表照数据原样列,不折大小写。
4.2 64 条全表
| # | NAME | DISPLAYNAME | KEY | MODIFIERKEY | EXCLUSIVE | HIDDEN | 来源 |
|---|---|---|---|---|---|---|---|
| 1 | Move/Attack | Move/Attack | LMB | true | false | KEYBIND | |
| 2 | Move | Move | ` | true | false | KEYBIND | |
| 3 | CastSKill | Cast Active Skill | RMB | true | false | KEYBIND | |
| 4 | InventoryMenu | Inventory | i | true | false | KEYBIND | |
| 5 | PetMenu | Pet Inventory | p | true | false | KEYBIND | |
| 6 | HoldPosition | Hold Position | SHIFT | false | false | KEYBIND | |
| 7 | AutoTarget | Auto-Target | a | false | true | KEYBIND | |
| 8 | AttackOnlyTargets | Attack Only Valid Targets | CTRL | true | false | KEYBIND | |
| 9 | Automap | Automap | m | true | false | KEYBIND | |
| 10 | AutomapReset | Automap Reset | m | SHIFT | true | false | KEYBIND |
| 11 | AutomapVisible | Map Visibility | m | CTRL | true | false | KEYBIND |
| 12 | SkillMenu | Skill Menu | s | true | false | KEYBIND | |
| 13 | StatMenu | Character Stats Menu | c | true | false | KEYBIND | |
| 14 | OptionsMenu | Options Menu | ESC | true | false | KEYBIND | |
| 15 | CloseAll | Close All Menus | SPACE | true | false | KEYBIND | |
| 16 | Journal | Arcane Statistics | j | true | false | KEYBIND | |
| 17 | WeaponSwap | Weapon Swap | w | true | false | KEYBIND | |
| 18 | SkillSwap | Active Skill Swap | TAB | true | false | KEYBIND | |
| 19 | QuestMenu | Quest Menu | q | true | false | KEYBIND | |
| 20 | ShowItems | Show Items | ALT | true | false | KEYBIND | |
| 21 | CycleSkillDown | Cycle Skills Down | , | true | false | KEYBIND | |
| 22 | CycleSkillUp | Cycle Skills Up | . | true | false | KEYBIND | |
| 23 | AutomapZoomOut | Automap Zoom Out | [ | true | false | KEYBIND | |
| 24 | AutomapZoomIn | Automap Zoom In | ] | true | false | KEYBIND | |
| 25 | CameraZoomOut | Camera Zoom Out | MWDOWN | true | false | KEYBIND | |
| 26 | CameraZoomIn | Camera Zoom In | MWUP | true | false | KEYBIND | |
| 27 | ResetCamera | Reset Camera | MMB | true | false | KEYBIND | |
| 28 | F1 | Function 1 | F1 | true | false | KEYBIND | |
| 29 | F2 | Function 2 | F2 | true | false | KEYBIND | |
| 30 | F3 | Function 3 | F3 | true | false | KEYBIND | |
| 31 | F4 | Function 4 | F4 | true | false | KEYBIND | |
| 32 | F5 | Function 5 | F5 | true | false | KEYBIND | |
| 33 | F6 | Function 6 | F6 | true | false | KEYBIND | |
| 34 | F7 | Function 7 | F7 | true | false | KEYBIND | |
| 35 | F8 | Function 8 | F8 | true | false | KEYBIND | |
| 36 | F9 | Function 9 | F9 | true | false | KEYBIND | |
| 37 | F10 | Function 10 | F10 | true | false | KEYBIND | |
| 38 | F11 | Function 11 | F11 | true | false | KEYBIND | |
| 39 | F12 | Function 12 | F12 | true | false | KEYBIND | |
| 40 | QUICKSAVE | Quick Save | F11 | CTRL | true | false | KEYBIND |
| 41 | QK1 | Quickslot 1 | 1 | true | false | QUICKKEYS | |
| 42 | QK2 | Quickslot 2 | 2 | true | false | QUICKKEYS | |
| 43 | QK3 | Quickslot 3 | 3 | true | false | QUICKKEYS | |
| 44 | QK4 | Quickslot 4 | 4 | true | false | QUICKKEYS | |
| 45 | QK5 | Quickslot 5 | 5 | true | false | QUICKKEYS | |
| 46 | QK6 | Quickslot 6 | 6 | true | false | QUICKKEYS | |
| 47 | QK7 | Quickslot 7 | 7 | true | false | QUICKKEYS | |
| 48 | QK8 | Quickslot 8 | 8 | true | false | QUICKKEYS | |
| 49 | QK9 | Quickslot 9 | 9 | true | false | QUICKKEYS | |
| 50 | QK0 | Quickslot 0 | 0 | true | false | QUICKKEYS | |
| 51 | BESTPOTION | Best Health Potion | z | false | false | QUICKKEYS | |
| 52 | BESTMANA | Best Mana Potion | x | false | false | QUICKKEYS | |
| 53 | BESTPETPOTION | Best Pet Health Potion | z | SHIFT | false | false | QUICKKEYS |
| 54 | BESTPETMANA | Best Pet Mana Potion | x | SHIFT | false | false | QUICKKEYS |
| 55 | Console | Console | INSERT | false | true | HELPERBINDINGS | |
| 56 | FPS | FPS Details | F | CTRL | false | true | HELPERBINDINGS |
| 57 | SoundDebug | Sound Debug | S | CTRL | false | true | HELPERBINDINGS |
| 58 | PlayerEffectData | Player Effect Data | E | CTRL | false | true | HELPERBINDINGS |
| 59 | PlayerQuestData | Player Quest Data | Q | CTRL | false | true | HELPERBINDINGS |
| 60 | CombatLog | CombatLog | c | CTRL | false | true | HELPERBINDINGS |
| 61 | TimeOfDay | Time Of Day | T | CTRL | false | true | HELPERBINDINGS |
| 62 | LevelData | Level Data | L | CTRL | false | true | HELPERBINDINGS |
| 63 | PeerData | Peer Data | P | CTRL | false | true | HELPERBINDINGS |
| 64 | SteamStatsData | Steam Stats Data | S | CTRL | false | true | HELPERBINDINGS |
4.3 数据里本来就有的冲突
同一个 (KEY, MODIFIERKEY) 出现多次是原版数据的性质,不是解析错误:
| 键 | 绑定 | 性质 |
|---|---|---|
m / SHIFT+m / CTRL+m | Automap / AutomapReset / AutomapVisible | 靠 MODIFIERKEY 区分,正常 |
z / SHIFT+z,x / SHIFT+x | BESTPOTION / BESTPETPOTION,BESTMANA / BESTPETMANA | 同上,正常 |
c / CTRL+c | StatMenu / CombatLog | 同上,正常 |
q / CTRL+Q | QuestMenu / PlayerQuestData | 同上,正常 |
CTRL+S(两次) | SoundDebug / SteamStatsData | 真冲突。两条都在 HELPERBINDINGS.DAT,都 EXCLUSIVE:false、都 HIDDEN:true。谁赢没查 |
CTRL+S 是把单字符大小写折叠后查出来的唯一一处同键同修饰键重复。引擎侧怎么裁决、EXCLUSIVE 在这种场合起不起作用,没逆。
4.4 w / s / a:WASD 的三条路障
表里第 17、12、7 行:
| 键 | 原版绑定 | HIDDEN |
|---|---|---|
w | WeaponSwap(换武器) | false |
s | SkillMenu(技能面板) | false |
a | AutoTarget(自动索敌) | true |
64 条里没有一条 KEY 是 d。TL2 原生没有 WASD 移动——移动是左键 Move/Attack(§1)。任何「加 WASD」的 MOD 都得先把这三条挪开,其中 a 那条是隐藏绑定,界面上看不见,容易漏。
4.5 EXCLUSIVE / HIDDEN 的取值规则:只看首字符
TL2 把字符串转 bool 的规则是:非空且首字符 ∉ {F, f, 0} ⇒ 真。⇒ MOD 里写 EXCLUSIVE:no、HIDDEN:off,引擎读到的都是 true。想写假,用 false / F / 0 开头。
TIP
这对做 MOD 意味着什么
- 做 WASD 类 MOD,第一步不是加绑定,是把
w/s/a三条原版绑定挪开(a是隐藏的)。 - 复制原版 HELPERBINDINGS.DAT 改的时候,
CTRL+S那两条已经是重的,别把它当成自己改出来的 bug。 - 布尔字段别写
no/off——它们是真。
5. 未闭环与已知偏差清单
下表把材料里标为「没逆 / 按名字推的 / 未实现 / 已知偏差」的项全部列出。「复刻实现侧」指照本条口径接进 Godot 竞技场的那份实现,它与引擎的差别也一并记在这里。
| 项 | 现状 |
|---|---|
sub_544200(启动移动/攻击)与 CCharacter::castSelectedSkill 的内部 | 没逆。本文只到「什么时候下单、下给谁」为止。复刻侧的伤害走 core-sim 的 Attack(),与原版的技能执行不是一条路 |
HoldPosition(SHIFT) | 引擎行为没逆。复刻侧按名字实现成「按住不挪窝」 |
AttackOnlyTargets(CTRL)、Move(`) | 引擎行为没逆。复刻侧只解析了绑定,没有实现 |
| 光标拾取 | 引擎按渲染包围盒拾取(能点到怪的头);复刻侧用「地面点落在脚印圆里」近似。后果是偶尔点了没反应,已知偏差 |
| 高度门在复刻侧的开关 | 常量 MELEE_HEIGHT_GATE = 2.5 留着,开关 MELEE_HEIGHT_GATE_ENABLED 默认关。接上之后近战判成永远够不着(hits=0):echopass_jt 水平只差 0.28u 而 dy = 9.18(怪 y=8.74、玩家 y=−0.44),echopass_pb dy = 6.18,两张图都是——复刻侧怪的节点 Y 与玩家物理体的 Y 不在同一个基准上。别把开关读成「引擎没有高度门」,引擎有;Y 差 6–9u 的成因没查 |
reach 在复刻侧 | 用常量 1.5(0.5+0.25+0.75,来自 DAT 硬编默认值表 B′);没有远程分支;r自己 折进了 reach。引擎是 技能的+108 × 单位的+1588 并被武器抬高。没接 |
技能 +108、单位 +1588、技能 +36、stat 78、unittype 0x23/0xBC/0x74 对应的 DAT 键名 / UNITTYPES 名字 | 材料里只有偏移与下标,没有名字 |
CTRL+S 两条绑定谁赢 | 没查。键位工具只报冲突,不裁决 |
KEY / MODIFIERKEY 字符串在引擎侧怎么解析(大小写、具名键) | 没逆 |
| 寻路 | 原版走的是走格 A*:sub_5425A0 → sub_464010(世界→格)→ sub_463EA0(A* core)。复刻侧是直线 + 轴向贴墙滑(先只走 X、再只走 Z、都不行就原地),凹角里死锁——实测杀掉第一只后玩家停在 (49.7, 88.4)、下一只在 12.1u 外、moving=true,位置 3,000 帧不动。没做 |
| 测量覆盖 | 复刻侧只在 echopass_jt / echopass_pb 上量过,一个种子 |
附录 A:虚地址与字节锚点表(Torchlight2.exe,ImageBase 0x400000)
| 功能 | 虚地址 / 符号 |
|---|---|
| 鼠标消息入口 | sub_43D610 |
| 点击派发 | sub_439750 |
| 每 tick 命令处理器 / order 处理器 | sub_439180 / sub_438080 |
| 移动/攻击分叉 | sub_42A4E0(controller, a5);主目标 controller+488、次目标 controller+512 |
| 靠近 / 转向(虚表) | vt+512(player, 4, 0)(移动模式 4)/ vt+584 |
| 启动移动/攻击(内部没逆) | sub_544200;攻击节奏亦涉及 sub_545070 |
| 纯移动(0.1 秒节流) | sub_426DC0,*(float*)(this+56) = 0.1 |
| 点击下单 call(a5=1) | 0x43A8FA,9 B:6A 01 8B CD E8 DD FB FE FF |
| 按住持续下单门 | 0x43A903,20 B:80 7D 52 00 75 0D 83 C8 FF A3 F8 DB 87 02 A3 FC DB 87 02 C6 |
| 待处理点击目标 | dword_287DBF8 / dword_287DBFC |
| 松手清标志 | 0x43A69E([ebp+52h] 清 0) |
| 攻击距离判定 | CCharacter::isTargetInAttackRange = sub_5350C0 |
| 高度门 | 0x5351FE,18 B:D8 1D F8 FD 16 02 DF E0 F6 C4 41 75 08 84 DB 0F 84 D9 |
| 高度门常量 2.5 | 0x216FDF8,4 B:00 00 20 40 |
| reach 的技能侧因子 | sub_52A9D0(当前技能 +108,被武器抬高,双持取大) |
| 装备槽读取 | CInventory::getItemAtEquipLocation(inv, 1) / (inv, 2) |
| 远程 unittype 判定 | hasUnitType(0x23) 且非 0xBC / 0x74 |
| 远程加成 | getStat(78, 7, 0) |
| 原版寻路 | sub_5425A0 → sub_464010 → sub_463EA0 |
附录 B:材料来源
- 真值账本
truth/ledger.jsonl:C-0172(must-exact/ O0 /guarded),本文的主干。 - 证据 JSON
原版游戏分析/reversed_cpp/REGISTRY/attack_trigger_semantics.json:调用链、分叉、字节锚点、攻击距离公式、复刻侧实测与未闭环清单。 - 键位工具
tools/f2_keybinds.py:三份 KEYMAPPING DAT 的解析口径、冲突报告、bool 首字符规则、project.godot[input]段生成。 - 键位数据
build/f2/keybinds.json:64 条绑定原始串与普查数字(§4 全表的来源)。 - 引擎数据
MEDIA/KEYMAPPING/{KEYBIND,QUICKKEYS,HELPERBINDINGS}.DAT:第二 oracle。