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:
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user