sql: datos de ítems del cliente 3.4.3 extraídos de los DB2 (es/en)

En 3.4.3 `item_template` ya no existe: los datos del juego viven en ficheros
DB2 del cliente, y aquí acore_world está vacío. Se extraen a `home_item_data`
(44873 ítems): nombre es/en, calidad, nivel de objeto, tipo de inventario y
displayId.

No hay ninguna librería en PyPI que lea WDC4 (wdbx, dbc-extract3, wow-db2 no
existen), así que el lector va incluido en sql/db2/:

- `wdc_db2.py`: WDC3/WDC4 con registros fijos (bitpacked, common y pallet).
- `itemsparse.py`: ItemSparse es una tabla *sparse*, que es otro mundo: los
  locstring van inline y terminados en \0, así que desplazan todo lo que va
  detrás y los offsets de field_storage_info no valen. Hay que recorrer los
  campos en orden, y el fichero no dice cuáles son strings: eso sale de las
  definiciones oficiales de WoWDBDefs (ItemSparse.dbd, incluido), casando el
  layout_hash del propio fichero (D532973D = 3.4.3).
- `db2_to_sql.py`: genera sql/item_data.sql. Regenerable y multi-locale: basta
  con dejar el ItemSparse.db2 de cada idioma en /root/<locale>/.

Verificado: los 3068 ítems de la tienda están cubiertos; 2949 nombres (96%)
coinciden literalmente con el catálogo antiguo y los 119 restantes son
revisiones de traducción de Blizzard, no fallos del parser ("Bordón de Luz" ->
"Batuta de Luz"). El ítem 45327 sale q4 / ilvl 238 / "Siren's Cry", idéntico a
lo que devuelve wowhead. La distribución de calidad es realista (53
legendarios, 23 artefactos).

Ojo: la tabla está cargada pero TODAVÍA no la usa ningún código de la web.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 08:24:34 +00:00
parent 14ebd1e1f1
commit 4ba9c42829
5 changed files with 48113 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python
"""Genera web-next/sql/item_data.sql a partir de los DB2 del cliente 3.4.3.
USO: /root/NovaWoW/.venv/bin/python /root/db2_to_sql.py
(el python del sistema no tiene pip/venv; usa SIEMPRE el del proyecto)
En 3.4.3 no existe `item_template`: los datos van en DB2 del cliente.
Locales: los nombres salen de <LOCALE>/ItemSparse.db2. Basta con dejar el
ItemSparse.db2 de cada idioma en /root/<locale>/ — el resto de tablas
(ItemAppearance, ItemModifiedAppearance) NO dependen del idioma y se leen del
primer locale que exista. Si falta un locale, su columna queda a NULL.
Ficheros que necesita:
/root/esES/ItemSparse.db2, ItemAppearance.db2, ItemModifiedAppearance.db2
/root/enUS/ItemSparse.db2 (opcional; para name_en)
/root/ItemSparse.dbd (definiciones oficiales de WoWDBDefs)
/root/wdc_db2.py, /root/itemsparse.py
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import wdc_db2
from itemsparse import ItemSparse
DBD = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'ItemSparse.dbd')
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'item_data.sql')
# (columna_sql, carpeta). Añadir aquí si aparecen más idiomas.
LOCALES = [('name_es', '/root/esES'), ('name_en', '/root/enUS')]
def load_names(folder):
path = f'{folder}/ItemSparse.db2'
if not os.path.exists(path):
print(f' (sin {path}: esa columna queda NULL)', file=sys.stderr)
return {}
t = ItemSparse(path, DBD)
return {i: r for i, r in t.rows}
def esc(s):
return s.replace('\\', '\\\\').replace("'", "''")
per_locale = {}
for col, folder in LOCALES:
print(f'{col}: leyendo {folder}', file=sys.stderr)
per_locale[col] = load_names(folder)
base_folder = next((f for _c, f in LOCALES if os.path.exists(f'{f}/ItemAppearance.db2')), None)
if not base_folder:
sys.exit('falta ItemAppearance.db2 en algún locale')
# displayId: ItemModifiedAppearance.ItemID -> ItemAppearanceID -> ItemDisplayInfoID.
# No depende del idioma. Solo lo tienen los equipables.
ia = {rid: row for rid, row in wdc_db2.DB2(f'{base_folder}/ItemAppearance.db2').rows}
display = {}
for _rid, row in wdc_db2.DB2(f'{base_folder}/ItemModifiedAppearance.db2').rows:
item_id, app_id, order = row[1], row[3], row[4]
if app_id in ia:
prev = display.get(item_id)
if prev is None or order < prev[0]:
display[item_id] = (order, ia[app_id][1])
display = {k: v[1] for k, v in display.items()}
print(f'displayId: {len(display)} ítems equipables', file=sys.stderr)
name_cols = [c for c, _f in LOCALES]
entries = sorted({e for m in per_locale.values() for e in m})
with open(OUT, 'w', encoding='utf-8') as out:
cols_sql = ',\n'.join(
f" `{c}` varchar(255) DEFAULT NULL COMMENT 'nombre en {c[-2:]}'" for c in name_cols)
out.write(f"""-- Datos de ítems del cliente 3.4.3, extraídos de los DB2. NO editar a mano:
-- regenerar con `/root/NovaWoW/.venv/bin/python /root/db2_to_sql.py`.
-- En 3.4.3 no existe `item_template` (los datos viven en DB2 del cliente).
-- Parseado con las definiciones oficiales de WoWDBDefs (layout D532973D).
-- Los nombres son los de Blizzard en 3.4.3: ~4% difieren de los del catálogo
-- antiguo de la tienda por revisiones de traducción de Blizzard.
DROP TABLE IF EXISTS `home_item_data`;
CREATE TABLE `home_item_data` (
`entry` int unsigned NOT NULL COMMENT 'item id de WoW',
{cols_sql},
`quality` tinyint unsigned NOT NULL COMMENT '0 pobre .. 6 artefacto, 7 reliquia',
`item_level` smallint unsigned NOT NULL,
`inventory_type` tinyint unsigned NOT NULL COMMENT '0 = no equipable',
`display_id` int unsigned DEFAULT NULL COMMENT 'ItemDisplayInfoID; NULL si no es equipable',
PRIMARY KEY (`entry`),
KEY `idx_name_es` (`name_es`(64)),
KEY `idx_quality` (`quality`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""")
rows = []
for entry in entries:
names = []
any_name = False
for c in name_cols:
r = per_locale[c].get(entry)
n = (r or {}).get('Display_lang') or ''
names.append(f"'{esc(n)}'" if n else 'NULL')
any_name = any_name or bool(n)
if not any_name:
continue # ítems internos sin nombre en ningún idioma
ref = next((per_locale[c][entry] for c in name_cols if entry in per_locale[c]), {})
rows.append('(%d,%s,%d,%d,%d,%s)' % (
entry, ','.join(names), ref.get('OverallQualityID', 0) or 0,
ref.get('ItemLevel', 0) or 0, ref.get('InventoryType', 0) or 0,
display.get(entry, 'NULL')))
cols = ','.join(f'`{c}`' for c in name_cols)
for i in range(0, len(rows), 500):
out.write(f'INSERT INTO `home_item_data` (`entry`,{cols},`quality`,`item_level`,`inventory_type`,`display_id`) VALUES\n')
out.write(',\n'.join(rows[i:i + 500]))
out.write(';\n')
print(f'escritas {len(rows)} filas en {OUT}', file=sys.stderr)
+108
View File
@@ -0,0 +1,108 @@
"""Extrae ItemSparse.db2 (WDC4, tabla sparse) usando las definiciones oficiales
de WoWDBDefs para el layout_hash del propio fichero.
Por qué hace falta el DBD: en una tabla sparse los locstring van INLINE y
terminados en \0, así que desplazan todo lo que va detrás. Los offsets de
field_storage_info asumen 4 bytes por string y NO valen; hay que recorrer los
campos en orden. El fichero no dice qué campos son strings: eso está en el DBD.
"""
import re
import struct
import sys
def parse_dbd(path, layout_hash):
txt = open(path, encoding='utf-8').read()
# Tipos por columna (COLUMNS ... hasta línea en blanco)
cols = {}
block = txt.split('COLUMNS', 1)[1].split('\n\n', 1)[0]
for line in block.strip().split('\n'):
parts = line.split()
if len(parts) >= 2:
cols[parts[1].split('<')[0].rstrip('?')] = parts[0].split('<')[0]
# Bloque del layout pedido
blocks = txt.split('\n\n')
for b in blocks:
if f'LAYOUT {layout_hash}' in b:
fields = []
for line in b.strip().split('\n'):
if line.startswith(('LAYOUT', 'BUILD', 'COMMENT')):
continue
m = re.match(r'^(\$[^$]*\$)?([A-Za-z_0-9]+)(<[^>]*>)?(\[(\d+)\])?', line)
if not m:
continue
ann, name, size, _, arr = m.groups()
fields.append(dict(name=name, noninline='noninline' in (ann or ''),
array=int(arr) if arr else 1,
type=cols.get(name, 'int')))
return fields
raise SystemExit(f'layout {layout_hash} no encontrado')
class ItemSparse:
def __init__(self, db2_path, dbd_path):
d = self.d = open(db2_path, 'rb').read()
hdr = struct.unpack_from('<IIIIIIIIIHHIIIIIII', d, 4)
(self.record_count, self.field_count, self.record_size, _sts, _th, layout_hash,
self.min_id, self.max_id, _loc, self.flags, self.id_index, _tfc, _bdo,
_lcc, fsi_size, common_size, pallet_size, self.section_count) = hdr
self.fields = [f for f in parse_dbd(dbd_path, '%08X' % layout_hash) if not f['noninline']]
if len(self.fields) != self.field_count:
print(f'aviso: DBD tiene {len(self.fields)} campos y el fichero {self.field_count}',
file=sys.stderr)
o = 4 + struct.calcsize('<IIIIIIIIIHHIIIIIII')
self.sections = []
for _ in range(self.section_count):
(_tact, fo, rc, sts, ore, ils, rds, omic, ctc) = struct.unpack_from('<QIIIIIIII', d, o)
o += 40
self.sections.append(dict(file_offset=fo, record_count=rc, string_table_size=sts,
offset_records_end=ore, id_list_size=ils,
relationship_data_size=rds, offset_map_id_count=omic,
copy_table_count=ctc))
o += self.field_count * 4 # field_structure: no hace falta
self.storage = []
for _ in range(self.field_count):
(ob, sb, extra, t, c1, c2, c3) = struct.unpack_from('<HHIIIII', d, o); o += 24
self.storage.append(dict(size_bits=sb, type=t))
self.rows = []
for s in self.sections:
self._read(s)
def _read(self, s):
d = self.d
# Orden en disco: datos variables, id_list, copy_table, offset_map
p = s['offset_records_end']
ids = list(struct.unpack_from(f'<{s["id_list_size"] // 4}I', d, p))
p += s['id_list_size']
p += s['copy_table_count'] * 8
omap = [struct.unpack_from('<IH', d, p + i * 6) for i in range(s['offset_map_id_count'])]
for i, (off, size) in enumerate(omap):
if not size:
continue
self.rows.append((ids[i], self._record(d[off:off + size])))
def _record(self, rec):
out = {}
pos = 0 # en bytes: los campos de una sparse van alineados a byte
for fi, f in enumerate(self.fields):
if f['type'] == 'locstring':
end = rec.find(b'\x00', pos)
out[f['name']] = rec[pos:end].decode('utf-8', 'replace')
pos = end + 1
continue
# OJO: size_bits ya es el TOTAL del campo, arrays incluidos
# (StatPercentageOfSocket[10] -> 320 bits), no el de un elemento.
nbytes = self.storage[fi]['size_bits'] // 8
if not nbytes:
continue
if f['array'] == 1:
out[f['name']] = int.from_bytes(rec[pos:pos + nbytes], 'little')
pos += nbytes
out['__consumed'] = pos
out['__len'] = len(rec)
return out
+143
View File
@@ -0,0 +1,143 @@
"""Lector mínimo de DB2 WDC3/WDC4 (formato de wowdev.wiki).
Solo lo necesario para sacar columnas enteras: registros normales, bitpacked,
common data y pallet (indexed). No implementa strings ni arrays de pallet, que
no hacen falta para el mapeo item -> displayId.
"""
import struct
FIELD_COMPRESSION_NONE = 0
FIELD_COMPRESSION_BITPACKED = 1
FIELD_COMPRESSION_COMMON_DATA = 2
FIELD_COMPRESSION_BITPACKED_INDEXED = 3
FIELD_COMPRESSION_BITPACKED_INDEXED_ARRAY = 4
FIELD_COMPRESSION_BITPACKED_SIGNED = 5
def _bits(data, bit_offset, bit_size):
"""Lee bit_size bits a partir de bit_offset (little-endian)."""
byte = bit_offset // 8
shift = bit_offset % 8
nbytes = (shift + bit_size + 7) // 8
chunk = data[byte:byte + nbytes]
if len(chunk) < nbytes:
chunk = chunk + b'\x00' * (nbytes - len(chunk))
val = int.from_bytes(chunk, 'little')
return (val >> shift) & ((1 << bit_size) - 1)
class DB2:
def __init__(self, path):
self.d = d = open(path, 'rb').read()
self.magic = d[:4].decode()
if self.magic not in ('WDC3', 'WDC4'):
raise ValueError(f'formato no soportado: {self.magic}')
(self.record_count, self.field_count, self.record_size, self.string_table_size,
self.table_hash, self.layout_hash, self.min_id, self.max_id, self.locale,
self.flags, self.id_index, self.total_field_count, self.bitpacked_data_offset,
self.lookup_column_count, self.field_storage_info_size, self.common_data_size,
self.pallet_data_size, self.section_count) = struct.unpack_from('<IIIIIIIIIHHIIIIIII', d, 4)
o = 4 + struct.calcsize('<IIIIIIIIIHHIIIIIII')
# WDC4 mete aquí un bloque extra de "encrypted section" opcional; en la
# práctica de estos ficheros la cabecera de sección sigue directamente.
self.sections = []
for _ in range(self.section_count):
(tact, file_offset, rec_count, str_size, offset_records_end, id_list_size,
rel_size, offset_map_id_count, copy_count) = struct.unpack_from('<QIIIIIIII', d, o)
o += 40
self.sections.append(dict(file_offset=file_offset, record_count=rec_count,
string_table_size=str_size, offset_records_end=offset_records_end,
id_list_size=id_list_size, relationship_data_size=rel_size,
offset_map_id_count=offset_map_id_count, copy_table_count=copy_count))
# field_structure
self.fields = []
for _ in range(self.field_count):
size, pos = struct.unpack_from('<hH', d, o); o += 4
self.fields.append((size, pos))
# field_storage_info
self.storage = []
for _ in range(self.field_count):
(off_bits, size_bits, extra, stype, c1, c2, c3) = struct.unpack_from('<HHIIIII', d, o)
o += 24
self.storage.append(dict(offset_bits=off_bits, size_bits=size_bits,
extra=extra, type=stype, c1=c1, c2=c2, c3=c3))
self.pallet = d[o:o + self.pallet_data_size]; o += self.pallet_data_size
self.common_raw = d[o:o + self.common_data_size]; o += self.common_data_size
# Base de cada campo dentro de pallet_data: suma acumulada de los
# additional_data_size de los campos pallet anteriores (c1/c2 son los bits
# de bitpacking, NO un offset).
self.pallet_base = {}
acc = 0
for fi, st in enumerate(self.storage):
if st['type'] in (FIELD_COMPRESSION_BITPACKED_INDEXED,
FIELD_COMPRESSION_BITPACKED_INDEXED_ARRAY):
self.pallet_base[fi] = acc
acc += st['extra']
# common data: por campo, `extra`/8 pares (id, value)
self.common = {}
p = 0
for fi, st in enumerate(self.storage):
if st['type'] == FIELD_COMPRESSION_COMMON_DATA and st['extra']:
m = {}
for _ in range(st['extra'] // 8):
k, v = struct.unpack_from('<II', self.common_raw, p); p += 8
m[k] = v
self.common[fi] = m
self.rows = []
for s in self.sections:
self._read_section(s)
def _read_section(self, s):
d = self.d
o = s['file_offset']
if self.flags & 0x1: # sparse: offset map en vez de registros fijos
raise NotImplementedError('tabla sparse (offset map) no soportada')
rec_data = d[o:o + s['record_count'] * self.record_size]
o += s['record_count'] * self.record_size
o += s['string_table_size']
ids = None
if s['id_list_size']:
ids = list(struct.unpack_from(f'<{s["id_list_size"] // 4}I', d, o))
o += s['id_list_size']
# copy table y relationship data: no se usan aquí
for i in range(s['record_count']):
rec = rec_data[i * self.record_size:(i + 1) * self.record_size]
row = [self._field(rec, fi, i) for fi in range(self.field_count)]
rid = ids[i] if ids is not None else row[self.id_index]
# common data: valor por id, o el default (c1) si no está listado
for fi, st in enumerate(self.storage):
if st['type'] == FIELD_COMPRESSION_COMMON_DATA:
row[fi] = self.common.get(fi, {}).get(rid, st['c1'])
self.rows.append((rid, row))
def _field(self, rec, fi, rec_index):
st = self.storage[fi]
t = st['type']
if t == FIELD_COMPRESSION_NONE:
if st['size_bits'] == 0:
return 0
return _bits(rec, st['offset_bits'], st['size_bits'])
if t in (FIELD_COMPRESSION_BITPACKED, FIELD_COMPRESSION_BITPACKED_SIGNED):
v = _bits(rec, st['offset_bits'], st['size_bits'])
if t == FIELD_COMPRESSION_BITPACKED_SIGNED:
sign = 1 << (st['size_bits'] - 1)
if v & sign:
v -= 1 << st['size_bits']
return v
if t == FIELD_COMPRESSION_COMMON_DATA:
return None # se resuelve en _read_section, que sí conoce el id
if t in (FIELD_COMPRESSION_BITPACKED_INDEXED, FIELD_COMPRESSION_BITPACKED_INDEXED_ARRAY):
idx = _bits(rec, st['offset_bits'], st['size_bits'])
off = self.pallet_base[fi] + idx * 4
if off + 4 <= len(self.pallet):
return struct.unpack_from('<I', self.pallet, off)[0]
return 0
raise NotImplementedError(f'storage type {t}')
File diff suppressed because it is too large Load Diff