diablo2-web/scripts/pack-canonical-tbl.ts

577 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* scripts/pack-canonical-tbl.ts
*
* Extracts canonical Diablo II v1.13c `.tbl` string table entries (ENG & CHI)
* and `ItemStatCost.txt` tooltip formatting metadata (`descfunc`, `descval`,
* `descstrpos`, `descstrneg`, `descstr2`, `dgrp*`) from `samples/d2/` MPQ archives
* (`d2data.mpq`, `d2exp.mpq`, `Patch_D2.mpq`) into `src/data/canonical-tbl-strings.ts`.
*
* Ground Truth References:
* - D2MOO `source/D2Common/src/DataTbls/ItemsTbls.cpp` (`0x6FD55280` .. `0x6FD5E020`)
* - D2Client.dll `sub_6FB23F80` (`descfunc` 1..28 & `dgrp` 1..2)
*/
import * as fs from 'node:fs'
import * as path from 'node:path'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadStringTableChain } from '../src/game/item-names.ts'
import { parseTable } from '../src/game/tables.ts'
import {
ITEM_NAMES_ZH,
MAGIC_PREFIX_ZH,
MAGIC_SUFFIX_ZH,
RARE_PREFIX_ZH,
RARE_SUFFIX_ZH,
SKILL_NAMES_ZH,
CLASS_SKILL_NAMES_ZH,
CLASS_RESTRICTION_SUFFIX_ZH,
RUNE_INFO_ZH,
} from '../src/game/tooltip-i18n.ts'
const EXPANSION_CHI_FALLBACKS: Readonly<Record<string, string>> = {
strethereal: '无形的 (无法修复)',
ModStre9s: '无法破坏',
ModStre9t: '每秒修复 %d 点耐久度',
ModStre9u: '每 %d 秒修复 1 点耐久度',
ModStre9v: '自动恢复数量',
ModStre9i: '增加堆叠数量',
ModStre9c: '(以角色等级决定)',
ModStre9d: '(在夜间增加)',
ModStre9e: '(在白天增加)',
ModStre9f: '(在黎明附近增加)',
ModStre9g: '(在黄昏附近增加)',
ModStre9h: ' 次聚气的',
ModStre9w: '施展等级 %d',
ModStre9x: '当你挥击时',
ModStre9y: '当你被击中时',
ModStre9z: '当你击中敌人时',
ModStre10d: '(%d/%d 次聚气)',
ModStre10L: '几率施展',
ModStre10a: '聚气',
ModStre10b: '等级',
ModStre10c: '每级',
ModStre10e: '(',
ModStre10f: ')',
ModStre10g: '隐秘',
ModStre10h: '毒素免疫',
ModStre10i: '诅咒',
ModStre10j: '每个队伍成员',
ModStre10k: '踢击伤害:',
ItemExpansiveChanc1: '击中时有 %d%% 几率施展等级 %d %s',
ItemExpansiveChanc2: '受击时有 %d%% 几率施展等级 %d %s',
ItemExpansiveChancX: '攻击时有 %d%% 几率施展等级 %d %s',
ExInsertSockets: '可镶嵌于带凹槽的物品中',
StrGemX1: '头盔:',
StrGemX2: '盾牌:',
StrGemX3: '武器:',
StrGemX4: '护甲:',
GemXp1: '头盔:',
GemXp2: '盾牌:',
GemXp3: '武器:',
GemXp4: '护甲:',
WeaponDescOrb: '法珠等级',
ModStre8a: ' 德鲁伊技能',
ModStre8c: ' 凹槽',
ModStre8d: ' 对恶魔的攻击准确率',
ModStre8e: ' 对不死生物的攻击准确率',
ModStre8f: ' 对恶魔的伤害',
ModStre8g: '% 攻击准确率',
ModStre8h: ' 标枪与长矛技能',
ModStre8i: ' 被动与魔法技能',
ModStre8j: ' 弓与十字弓技能',
ModStre8k: ' 防御灵气技能',
ModStre8l: ' 攻击灵气技能',
ModStre8m: ' 战斗技能',
ModStre8n: ' 召唤技能',
ModStre8o: ' 毒素与白骨技能',
ModStre8p: ' 诅咒',
ModStre8q: ' 战嚎技能',
ModStre8r: ' 战斗技能',
ModStre8s: ' 战斗专家技能',
ModStre8t: ' 冰冷技能',
ModStre8u: ' 闪电技能',
ModStre8v: ' 火焰技能',
ModStre8w: ' 召唤技能',
ModStre8x: ' 变形技能',
ModStre8y: ' 元素技能',
ModStre8z: ' 陷阱技能',
ModStre9a: ' 影子训练技能',
ModStre9b: ' 武学艺术技能',
ice: '解冻安雅的药剂\n玛拉的药剂',
'0sc': '知识卷轴',
tr2: '右键点击施放\n抗性卷轴',
Armor: '防具',
Rings: '戒指',
'Class Specific': '职业专属',
// Expansion SuperUniques
'Siege Boss': '督军山克',
'Ancient Barbarian 1': '塔力克',
'Ancient Barbarian 2': '马道克',
'Ancient Barbarian 3': '科力克',
'Axe Dweller': '持斧者',
'Bonesaw Breaker': '碎骨破甲者',
'Dac Farren': '达克法恩',
'Megaflow Rectifier': '矫正者怪异',
'Eyeback Unleashed': '狂暴者眼魔',
'Threash Socket': '剥壳凹槽',
Pindleskin: '暴躁外皮',
'Snapchip Shatter': '碎冰冲击者',
'Anodized Elite': '地狱佳人',
'Vinvear Molech': '温韦尔·摩洛克',
'Sharp Tooth Sayer': '利齿杀手',
'Magma Torquer': '岩浆扭曲者',
'Blaze Ripper': '烈焰撕裂者',
Frozenstein: '弗罗森斯坦',
Nihlathak: '尼拉塞克',
'Nihlathak Town': '尼拉塞克',
'Baal Subject 1': '毁灭者卡兰索',
'Baal Subject 2': '被诅咒的阿克姆',
'Baal Subject 3': '血腥巴特克',
'Baal Subject 4': '不洁者凡塔',
'Baal Subject 5': '折磨者利斯特',
// Expansion Monsters & NPCs
'Baal Subject Mummy': '解开者',
BaalColdMage: '死亡法师',
'Wake of Destruction': '火焰之径',
'Charged Bolt Sentry': '充能弹卫哨',
'Lightning Sentry': '闪电卫哨',
'Blade Creeper': '刀刃守卫',
'Invis Pet': '仆从',
'Inferno Sentry': '地狱火卫哨',
'Death Sentry': '死亡卫哨',
'Shadow Warrior': '影子战士',
'Shadow Master': '影子大师',
'Druid Hawk': '乌鸦',
'Druid Spirit Wolf': '鬼狼',
'Druid Fenris': '狂狼',
'Spirit of Barbs': '荆棘之灵',
'Heart of Wolverine': '狼獾之心',
'Oak Sage': '橡木智者',
'Druid Plague Poppy': '瘟疫藤',
'Druid Cycle of Life': '太阳藤',
'Vine Creature': '食尸藤',
'Druid Bear': '灰熊',
Wolf: '狼',
Bear: '熊',
'Barricade Door': '路障门',
'Prison Door': '牢门',
'Barricade Tower': '路障塔',
'Siege Beast': '攻城兽',
SnowYeti1: '雪地流浪者',
SnowYeti2: '雪怪',
SnowYeti3: '寒冰怪',
SnowYeti4: '冰封深渊怪',
WolfRider1: '沉沦魔狼骑兵',
WolfRider2: '削肉者狼骑兵',
WolfRider3: '恶魔狼骑兵',
'Hell Temptress': '地狱女妖',
'Blood Temptress': '鲜血女妖',
'Blood Witch': '鲜血女巫',
'Hell Witch': '地狱女巫',
CatapultS: '投石车',
CatapultE: '投石车',
CatapultSiege: '投石车',
CatapultW: '投石车',
'Catapult Spotter S': '投石车',
'Catapult Spotter E': '投石车',
'Catapult Spotter Siege': '投石车',
'Catapult Spotter W': '投石车',
'Frozen Horror1': '冰冻潜行者',
'Frozen Horror2': '冰冻恐惧',
'Frozen Horror3': '冰冻灾祸',
'Frozen Horror4': '冰冻魔怪',
'Frozen Horror5': '冰冻焦灼者',
'Blood Lord1': '月亮之王',
'Blood Lord2': '暗夜之王',
'Blood Lord3': '鲜血之王',
'Blood Lord4': '地狱之王',
'Blood Lord5': '死亡之王',
Larzuk: '拉苏克',
Drehya: '安雅',
Malah: '玛拉',
'Qual-Kehk': '夸尔凯克',
'Act 5 Combatant': '野蛮人战士',
'Barricade Wall Right': '路障',
'Barricade Wall Left': '路障',
'Evil hut': '邪恶魔屋',
'Death Mauler1': '死亡重击者',
'Death Mauler2': '死亡斗士',
'Death Mauler3': '死亡斩击者',
'Death Mauler4': '死亡狂战士',
'Death Mauler5': '死亡统领',
POW: '被俘的野蛮人',
'Act 5 Townguard': '守卫',
'Ancient Statue 1': '守护者塔力克',
'Ancient Statue 2': '守卫者马道克',
'Ancient Statue 3': '保卫者科力克',
'Baal Throne': '巴尔',
'Baal Crab': '巴尔',
'Baal Taunt': '巴尔',
'Baal Crab to Stairs': '巴尔',
'Baal Tentacle': '腐烂触须',
'Baal Crab Clone': '巴尔',
'Baals Minion': '毁灭的仆从',
'Putrid Defiler1': '腐败玷污者',
'Putrid Defiler2': '卑劣玷污者',
'Putrid Defiler3': '恶臭玷污者',
'Putrid Defiler4': '酸臭玷污者',
'Putrid Defiler5': '腥臭玷污者',
'Pain Worm1': '痛苦之虫',
'Pain Worm2': '折磨之虫',
'Pain Worm3': '苦难之虫',
'Pain Worm4': '威胁之虫',
'Pain Worm5': '剧痛之虫',
}
async function main() {
const archives = new MountedArchives()
for (const m of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(m, await MpqArchive.open(await fileSource(`samples/d2/${m}`)))
}
const eng = await loadStringTableChain(archives, 'ENG')
const chi = await loadStringTableChain(archives, 'CHI')
const readTxt = async (name: string) =>
parseTable(new TextDecoder().decode(await archives.read(`data/global/excel/${name}`)))
const keys = new Set<string>()
const addKey = (k?: string) => {
const t = k?.trim()
if (t && t !== 'Expansion' && !t.startsWith('*')) {
keys.add(t)
}
}
// 1. Base items (weapons.txt, armor.txt, misc.txt)
for (const fn of ['weapons.txt', 'armor.txt', 'misc.txt']) {
const t = await readTxt(fn)
for (const r of t.rows) {
addKey(r['namestr'])
addKey(r['code'])
}
}
// 2. Uniques, SetItems, Sets, Runes, Gems, LowQualityItems
for (const fn of ['UniqueItems.txt', 'SetItems.txt']) {
const t = await readTxt(fn)
for (const r of t.rows) addKey(r['index'])
}
for (const r of (await readTxt('Sets.txt')).rows) {
addKey(r['name'])
addKey(r['index'])
}
for (const r of (await readTxt('Runes.txt')).rows) {
addKey(r['Name'])
}
for (const r of (await readTxt('Gems.txt')).rows) {
addKey(r['name'])
addKey(r['code'])
}
for (const r of (await readTxt('LowQualityItems.txt')).rows) {
addKey(r['Name'])
}
// 3. MagicPrefix, MagicSuffix, AutoMagic, RarePrefix, RareSuffix
for (const fn of ['MagicPrefix.txt', 'MagicSuffix.txt', 'AutoMagic.txt']) {
const t = await readTxt(fn)
for (const r of t.rows) addKey(r['Name'])
}
for (const fn of ['RarePrefix.txt', 'RareSuffix.txt']) {
const t = await readTxt(fn)
for (const r of t.rows) addKey(r['name'])
}
// 4. ItemStatCost.txt description & display group keys
const isc = await readTxt('ItemStatCost.txt')
interface IscDescMeta {
id: number
stat: string
descPriority: number
descFunc: number
descVal: number
descStrPos: string
descStrNeg: string
descStr2: string
dgrp: number
dgrpFunc: number
dgrpVal: number
dgrpStrPos: string
dgrpStrNeg: string
dgrpStr2: string
}
const iscMeta: Record<string, IscDescMeta> = {}
for (const r of isc.rows) {
const stat = r['Stat']?.trim()
if (!stat) continue
for (const col of ['descstrpos', 'descstrneg', 'descstr2', 'dgrpstrpos', 'dgrpstrneg', 'dgrpstr2']) {
addKey(r[col])
}
const descFunc = Number(r['descfunc']?.trim() || 0)
const dgrp = Number(r['dgrp']?.trim() || 0)
if (descFunc > 0 || dgrp > 0) {
iscMeta[stat] = {
id: Number(r['ID']?.trim() || 0),
stat,
descPriority: Number(r['descpriority']?.trim() || 0),
descFunc,
descVal: Number(r['descval']?.trim() || 0),
descStrPos: r['descstrpos']?.trim() || '',
descStrNeg: r['descstrneg']?.trim() || '',
descStr2: r['descstr2']?.trim() || '',
dgrp,
dgrpFunc: Number(r['dgrpfunc']?.trim() || 0),
dgrpVal: Number(r['dgrpval']?.trim() || 0),
dgrpStrPos: r['dgrpstrpos']?.trim() || '',
dgrpStrNeg: r['dgrpstrneg']?.trim() || '',
dgrpStr2: r['dgrpstr2']?.trim() || '',
}
}
}
// 5. CharStats.txt, SkillDesc.txt, MonStats.txt, SuperUniques.txt, Levels.txt
for (const r of (await readTxt('CharStats.txt')).rows) {
for (const col of ['StrAllSkills', 'StrSkillTab1', 'StrSkillTab2', 'StrSkillTab3', 'StrClassOnly', 'class']) {
addKey(r[col])
}
}
for (const r of (await readTxt('SkillDesc.txt')).rows) {
addKey(r['str name'])
}
for (const r of (await readTxt('MonStats.txt')).rows) {
addKey(r['NameStr'])
addKey(r['DescStr'])
}
for (const r of (await readTxt('SuperUniques.txt')).rows) {
addKey(r['Name'])
}
for (const r of (await readTxt('Levels.txt')).rows) {
addKey(r['LevelName'])
addKey(r['LevelWarp'])
addKey(r['EntryFile'])
}
// 6. All ModStr*, strMod*, ItemStats*, Weapon*, StrGem*, GemXp*, ExInsert*, UI/HUD/Monster/Elite/Immunity/Vendor keys
const explicitUiAndMonsterKeys = new Set([
'Socketable',
'gld',
'Gold',
'strGoldLabel',
'Hiquality',
'Cracked',
'Crude',
'Damaged',
'Low Quality',
'Gemmed',
'GemmedNormalName',
'Skillname2',
'Skilldesc2',
'Skillname3',
'Skilldesc3',
'Skillihand',
'RunOn',
'RunOff',
'bank',
'stash',
'strGoldInStash',
'box',
'strUiMenu2',
'strClose',
'close',
'TalkMenu',
'NPCIdentify1',
'NPCMenuTradeRepair',
'NPCMenuTrade',
'gamble',
'lowercasecancel',
'strBSArmor',
'strBSWeapons',
'strBSMisc',
'NPCPurchaseItems',
'NPCSellItems',
'NPCRepairItems',
'uniquextrastrong',
'uniqueextrafast',
'uniquecursed',
'uniquemagicresistance',
'uniquefireenchanted',
'UndeadDescriptX',
'DemonID',
'Champion',
'minion',
])
for (const [k] of eng.dict.entries()) {
if (
k.startsWith('ModStr') ||
k.startsWith('strMod') ||
k.startsWith('ItemStats') ||
k.startsWith('Weapon') ||
k.startsWith('StrGem') ||
k.startsWith('GemXp') ||
k.startsWith('ExInsert') ||
k.startsWith('strchr') ||
k.startsWith('StrSklTree') ||
k.startsWith('StrSkill') ||
k.startsWith('minipanel') ||
k.startsWith('monsteruniqueprop') ||
k.startsWith('strScaryMonDesc') ||
k.startsWith('champ') ||
k.startsWith('Eskill') ||
explicitUiAndMonsterKeys.has(k)
) {
keys.add(k)
}
}
// 7. MonStats.txt (NameStr, DescStr), SuperUniques.txt (Name), and 1.13c monster hover/affix/immunity keys
for (const r of (await readTxt('monstats.txt')).rows) {
addKey(r['NameStr'])
addKey(r['DescStr'])
}
for (const r of (await readTxt('SuperUniques.txt')).rows) {
addKey(r['Name'])
}
for (const k of [
'DemonID',
'UndeadDescriptX',
'Champion',
'minion',
'champghostlyX',
'champfanaticX',
'champpossessedX',
'champberserkX',
'uniquextrastrong',
'uniqueextrafast',
'uniquecursed',
'uniquemagicresistance',
'uniquefireenchanted',
'monsteruniqueprop1',
'monsteruniqueprop2',
'monsteruniqueprop3',
'monsteruniqueprop4',
'monsteruniqueprop5',
'monsteruniqueprop6',
'monsteruniqueprop7',
'monsteruniqueprop8',
'monsteruniqueprop9',
'strScaryMonDesc1',
'strScaryMonDesc2',
'strScaryMonDesc3',
'strScaryMonDesc4',
'strScaryMonDesc5',
'strScaryMonDesc6',
]) {
addKey(k)
}
const sortedKeys = Array.from(keys).sort()
const engOut: Record<string, string> = {}
const chiOut: Record<string, string> = {}
for (const k of sortedKeys) {
const ev = eng.get(k)
if (ev !== undefined) {
engOut[k] = ev
}
const evTrim = ev?.trim() ?? ''
const cv =
chi.get(k) ??
EXPANSION_CHI_FALLBACKS[k] ??
ITEM_NAMES_ZH[ev ?? ''] ??
ITEM_NAMES_ZH[evTrim] ??
ITEM_NAMES_ZH[k] ??
SKILL_NAMES_ZH[ev ?? ''] ??
SKILL_NAMES_ZH[evTrim] ??
SKILL_NAMES_ZH[k] ??
CLASS_SKILL_NAMES_ZH[ev ?? ''] ??
CLASS_SKILL_NAMES_ZH[evTrim] ??
CLASS_SKILL_NAMES_ZH[k] ??
CLASS_RESTRICTION_SUFFIX_ZH[ev ?? ''] ??
CLASS_RESTRICTION_SUFFIX_ZH[evTrim] ??
CLASS_RESTRICTION_SUFFIX_ZH[k] ??
RUNE_INFO_ZH[k]?.nameZh
if (cv !== undefined) {
chiOut[k] = cv
}
}
// Provide canonical alias keys for 3206, 3208, 3210..3218 so both raw .tbl keys and descriptive keys resolve
const monsterModAliases: ReadonlyArray<readonly [string, string]> = [
['uniquextrafast', 'uniqueextrafast'],
['uniquemagicresistant', 'uniquemagicresistance'],
['uniquecoldenchanted', 'monsteruniqueprop1'],
['uniquelightningenchanted', 'monsteruniqueprop2'],
['uniquemanaburn', 'monsteruniqueprop3'],
['uniquespectralhit', 'monsteruniqueprop4'],
['uniqueteleport', 'monsteruniqueprop5'],
['uniquestoneskin', 'monsteruniqueprop6'],
['uniquemultishot', 'monsteruniqueprop7'],
['UniqueAuraEnchanted', 'monsteruniqueprop9'],
]
for (const [alias, canonical] of monsterModAliases) {
if (engOut[canonical] !== undefined) engOut[alias] = engOut[canonical]!
if (chiOut[canonical] !== undefined) chiOut[alias] = chiOut[canonical]!
}
// Also include expansionstring.tbl index 2745 ("Ethereal (Cannot be Repaired)") under canonical key 'strethereal'
const expTbl = eng.tables[1]
if (expTbl && expTbl[2745]) {
engOut['strethereal'] = expTbl[2745]!
}
chiOut['strethereal'] = EXPANSION_CHI_FALLBACKS['strethereal']!
const outPath = path.join(process.cwd(), 'src/data/canonical-tbl-strings.ts')
const fileContent = `/**
* AUTO-GENERATED by \`scripts/pack-canonical-tbl.ts\` from authoritative 1.13c MPQ archives:
* - \`d2data.mpq\` (\`string.tbl\`)
* - \`d2exp.mpq\` (\`expansionstring.tbl\`)
* - \`Patch_D2.mpq\` (\`patchstring.tbl\`)
* - \`data/global/excel/ItemStatCost.txt\`
*
* DO NOT EDIT MANUALLY.
*/
export interface CanonicalIscDescEntry {
readonly id: number
readonly stat: string
readonly descPriority: number
readonly descFunc: number
readonly descVal: number
readonly descStrPos: string
readonly descStrNeg: string
readonly descStr2: string
readonly dgrp: number
readonly dgrpFunc: number
readonly dgrpVal: number
readonly dgrpStrPos: string
readonly dgrpStrNeg: string
readonly dgrpStr2: string
}
export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = ${JSON.stringify(engOut, null, 2)}
export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = ${JSON.stringify(chiOut, null, 2)}
export const CANONICAL_ISC_DESC_TABLE: Readonly<Record<string, CanonicalIscDescEntry>> = ${JSON.stringify(iscMeta, null, 2)}
export function resolveTblKey(key: string, lang: 'CHI' | 'ENG' = 'CHI'): string | undefined {
const dict = lang === 'ENG' ? CANONICAL_ENG_TBL_DICT : CANONICAL_CHI_TBL_DICT
return dict[key] ?? dict[key.trim()]
}
`
fs.writeFileSync(outPath, fileContent, 'utf8')
console.log(
`Wrote ${outPath}: ${Object.keys(engOut).length} ENG keys, ${Object.keys(chiOut).length} CHI keys, ${Object.keys(iscMeta).length} ISC entries.`,
)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})