From 1de3d12c5d66fc76dc4f02b1a7008d3f7178665b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Hillerstr=C3=B6m?= Date: Thu, 16 Jul 2026 16:13:26 +0200 Subject: [PATCH 1/8] db2tool: pure-Go DB2 extractor core (dbd parser, WDC5 decoder, sqlite writer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First stage of docs/db2tool-migration-plan.md: everything downstream of file extraction, fed by pre-extracted .db2 files. - dbd/: BSD-3 port of DBDefsLib's DBDReader + the SQLite helpers' exact-build version selection (last block whose builds contain the trailing build number; fail-loud when WoWDBDefs lacks the build). - wdc/: MIT port of DBCD.IO's WDC5Reader/WDC4Row/BitReader — multi-section, all 6 compression modes, sparse offset-map, copy tables, trailing relations, negative-base string offsets, and the keyless encrypted-section skip (TactKeyLookup != 0 + zero-filled data). - sqlite/: port of this repo's SQLiteDbCreator/SqliteDataInserter via modernc.org/sqlite (delete-first lifecycle, generated [Col_i] VIRTUAL columns, table-less idx_ names in settings order, relation 0 stays 0, float32 marshaling, C# Array-declared JSON serialization semantics). - Temporary driver (main.go) decodes tools/DB2ToSqlite/dbfilesclient against DBDCache for an explicit --build; a follow-up commit replaces this with local CASC extraction. - internal/golden + tests: schema DDL gate, per-table row parity, the float-notation assertion from plan §5.5, modernc marshaling smoke test. Validation vs the Jul 11 dotnet reference (build 5.5.4.68571, with hotfixes): sqlite_master schema byte-identical (236 objects); row dumps byte-identical for 67/72 tables; the 9 differing lines are exactly the DBCache hotfix overlay (add item 272920/spell 1291315 'Spring Panda', delete item 277947, modify spell 1298412) plus the documented CurvePoint float-notation case (slack table). gen_db against the Go-built DB reproduces the committed assets/database/db.json and leftover_db.json byte-for-byte (.bin files are nondeterministic even from the reference DB). --- docs/db2tool-migration-plan.md | 433 ++++++++++++++++++ tools/db2tool/NOTICES.md | 74 +++ tools/db2tool/config/config.go | 53 +++ tools/db2tool/dbd/dbd.go | 540 ++++++++++++++++++++++ tools/db2tool/dbd/dbd_test.go | 128 ++++++ tools/db2tool/dbd/select.go | 29 ++ tools/db2tool/golden_test.go | 189 ++++++++ tools/db2tool/internal/golden/golden.go | 166 +++++++ tools/db2tool/main.go | 150 ++++++ tools/db2tool/sqlite/insert.go | 135 ++++++ tools/db2tool/sqlite/schema.go | 125 +++++ tools/db2tool/sqlite/sqlite_test.go | 134 ++++++ tools/db2tool/wdc/bitreader.go | 89 ++++ tools/db2tool/wdc/row.go | 418 +++++++++++++++++ tools/db2tool/wdc/wdc5.go | 577 ++++++++++++++++++++++++ tools/db2tool/wdc/wdc5_test.go | 123 +++++ 16 files changed, 3363 insertions(+) create mode 100644 docs/db2tool-migration-plan.md create mode 100644 tools/db2tool/NOTICES.md create mode 100644 tools/db2tool/config/config.go create mode 100644 tools/db2tool/dbd/dbd.go create mode 100644 tools/db2tool/dbd/dbd_test.go create mode 100644 tools/db2tool/dbd/select.go create mode 100644 tools/db2tool/golden_test.go create mode 100644 tools/db2tool/internal/golden/golden.go create mode 100644 tools/db2tool/main.go create mode 100644 tools/db2tool/sqlite/insert.go create mode 100644 tools/db2tool/sqlite/schema.go create mode 100644 tools/db2tool/sqlite/sqlite_test.go create mode 100644 tools/db2tool/wdc/bitreader.go create mode 100644 tools/db2tool/wdc/row.go create mode 100644 tools/db2tool/wdc/wdc5.go create mode 100644 tools/db2tool/wdc/wdc5_test.go diff --git a/docs/db2tool-migration-plan.md b/docs/db2tool-migration-plan.md new file mode 100644 index 0000000000..85a59b32df --- /dev/null +++ b/docs/db2tool-migration-plan.md @@ -0,0 +1,433 @@ +# Migration Plan: Reimplement `tools/DB2ToSqlite` (.NET 9) in Pure Go + +Author target: wowsims/mop maintainer, Go-fluent, repo-familiar; phases are contributor-handoff-ready. This is a **plan, not an implementation**. + +Two hard constraints govern every decision below: + +- **Minimal API surface.** Each ported component implements *only* what the configured `Tables[]` / `GameTables[]` → `wowsims.db` path exercises for the current live MoP-Classic build. No whole-library ports. +- **Licensing.** Per-file notices as spelled out in §4; CC BY-SA `.dbd` data is fetched at build time, never vendored into the MIT tree; `WoW.txt` TACT keys are user-supplied, never vendored. + +> **Revised after maintainer review + verification against the vendored `.db2` files and the maintainer's live install.** (1) the build is **not pinned** — the tool tracks the live game and is re-run on every patch/hotfix (§1); (2) the committed `db.json` is built **with hotfixes**, so Phase D is **required** for parity (§6, §7 H4); (3) **the current tool uses NO TACT keys** — verified: every encrypted DB2 section in the vendored `.db2` is zero-filled and skipped, so the shipped data simply omits a small amount of pre-release content. Decrypting (Salsa20 + `WoW.txt`) is an **optional future enhancement**, not needed for parity (§7 C1, §4). (4) **[adversarial review 2026-07-16] the current tool never actually reads local CASC** — `Program.cs:41` constructs `BuildInstance()` without passing the JSON-bound settings, so `Settings.BaseDir` stays null and TACTSharp fetches configs, group/file indices, encoding, root, and every `.db2`/gametable byte from the **Blizzard CDN** into `tools/DB2ToSqlite/cache/` (~1.2 GB; blob timestamps match the vendored `.db2` to the minute). The local install supplies only `.build.info` and `DBCache.bin` (§2.1 step 5, §6 Phase B, §7 C3). The live install's local CASC files were separately verified *present and well-formed* — `.build.info`, `.idx` v7, archives, WoW root + TVFS (§7 C3, §10 Q4) — but they are **not what the current tool reads**; the planned local-first port is a deliberate behavior change, de-riskable with a one-line dotnet patch before any Go is written (§1 Stance, §6 Phase B). + +--- + +## 1. Executive summary + +`make db` / `make ptrdb` currently run a .NET 9 tool (`tools/DB2ToSqlite`) that extracts the live World of Warcraft build (MoP Classic, `wow_classic` / PTR `wow_classic_ptr`; the build is identified via the local install's `.build.info`, but the file bytes come from the Blizzard CDN — see the revision note above) into `tools/database/wowsims.db` plus 8 basestats `.txt` files, then run the existing Go generator (`tools/database/gen_db/*.go`) to emit the shipped `assets/database/db.{bin,json}`. This plan replaces the .NET half with a pure-Go tool at `tools/db2tool/`, removing dotnet from the build entirely. + +**What changes** + +- The first stage of `make db` / `make ptrdb` changes from `dotnet run` to `go run ./tools/db2tool ...`. Target names, settings files, and the second (`gen_db`) stage are unchanged. +- Four vendored .NET DLLs (TACTSharp, DBCD, DBCD.IO, DBDefsLib), the NuGet `Microsoft.Data.Sqlite` dependency, the `.csproj`, and the `.sln` entry are deleted. + +**What does not change (the drop-in contract)** + +- **`tools/database/wowsims.db`** — the SQLite schema, generated `[Col_i]` VIRTUAL columns, JSON-array text encoding, and exact-build column set are a frozen integration contract consumed by `tools/database/*.go` (sole reader: `dbhelper.go:22`, `sql.Open("sqlite", DatabasePath)` via `modernc.org/sqlite`). +- **`assets/db_inputs/basestats/*.txt`** — 8 GameTables copied verbatim. +- **`tools/DB2ToSqlite/listfile.csv`** — a *second* output contract (see §5), hardcoded in three downstream Go files. +- **`assets/database/db.{bin,json}` + `leftover_db.{bin,json}`** — the committed, shipped goldens produced by the unchanged `gen_db` stage. These are the true end-to-end acceptance target. + +**Stance** + +- **Pure Go, no cgo.** The existing `modernc.org/sqlite v1.37.0` (pure Go) writes the output — the same driver the reader already uses. All decompression/crypto is stdlib (`compress/zlib`, `crypto/md5`, `encoding/binary`) plus, only if a needed file is ever encrypted, `golang.org/x/crypto/salsa20`. The repo's one cgo file (`sim/lib/library.go`) is a separate `c-shared` target and is not on the `make db` path. +- **Keep the SQLite intermediate.** Do not go direct-to-`dbc`; the schema *is* the contract and keeping it makes the port a true drop-in and gives a clean per-half validation seam. +- **Local-install-first — a deliberate behavior change, not the status quo.** The current tool is CDN-fed (revision note above); the port reads the full local install instead. Same build config → same CKeys → same bytes, so parity is expected, but it must be *proven*: Phase B gate 1 byte-diffs the locally-extracted `.db2` against the CDN-sourced vendored ones. **Cheap de-risk before writing any Go:** TACTSharp's `Settings` fields are public and Program.cs already mutates them, so a one-line dotnet patch (`buildInstance.Settings.BaseDir = settings.BaseDir;`) makes the *current* tool exercise the local path — run it once and byte-diff the outputs (§6 Phase B pre-flight). CDN/Ribbit in Go stays deferred to an explicit, optional phase (§6 Phase C); porting CDN-first instead is the strict-parity fallback if local extraction ever proves incomplete. + +**Live build, not pinned.** The tool always targets **whatever build the local install currently is** (via `.build.info`); `5.5.4.68571` was current at analysis time (listed verbatim in all 72 cached `.dbd`, with build-specific unnamed columns `Field_1_15_3_55112_014` / `Field_1_15_7_59706_054`). It is re-run whenever Blizzard patches or new hotfixes land, so the build number, the required `.dbd` (WoWDBDefs must already contain the new build), and the WDC format version are all **moving targets** the tool must track — not constants to hardcode. PTR differs by `Product = "wow_classic_ptr"` and, concretely, a *different build*: on the live install right now `wow_classic` = `5.5.4.68571` while `wow_classic_ptr` = `5.5.4.67849`. + +**Required per-run inputs (all track the live game, none committed):** the `.dbd` schemas (fetched from WoWDBDefs) and the `listfile.csv` (path→FDID). **No TACT keys are used** — encrypted DB2 sections are skipped (§7 C1); a `WoW.txt` would only be needed if you later choose to decrypt pre-release content (§4). The committed `db.json` is generated **with hotfixes** applied (§6 Phase D). + +**Overall effort: L–XL**, dominated by the WDC5 decoder and the local CASC/TACT reader. + +--- + +## 2. Current pipeline (as-is) + +### 2.1 `make db` data flow + +``` +make db (makefile:249-255) + ├─ cd tools/DB2ToSqlite + │ └─ dotnet run -- -s --output + │ (ptrdb: ptr-generator-settings.json; only Product differs) + │ + │ Program.cs (126 lines), 11 steps: + │ 1. parse --settings/-s, --output/-o + │ 2. load JSON: Settings→BindableSettings:TACTSharp.Settings + Tables[72],GameTables[8], + │ GameTablesOutDirectory="../../assets/db_inputs/basestats", TargetDirectory="dbfilesclient" + │ 3. Listfile.Initialize(CDN, settings) [downloads/caches 148 MB listfile.csv via HTTP, path→FDID] + │ 4. BuildInfo(BaseDir/.build.info) [pick entry where Product==settings.Product] + │ 5. LoadConfigs(BuildConfig,CDNConfig); Load() [configs + group/file indices + encoding + root + install + │ ALL fetched from the Blizzard CDN into cache/ — + │ Program.cs:41 creates BuildInstance() with a fresh default + │ Settings (BaseDir=null, never copied from the JSON settings), + │ so cdn.OpenLocal() is never called; local .idx/data.NNN unread] + │ 6. for GameTables: OpenFileByFDID(GetFDID("gametables/.txt")) → write raw bytes to basestats dir + │ 7. for Tables: OpenFileByFDID(GetFDID("/.db2")) → write /.db2; + │ fetch .dbd; DBCD.Load [TargetDirectory does double duty: listfile-key prefix AND + │ output dir (Program.cs:77-78) — see §7 M4 carve-out] + │ 8. buildNumber = uint.Parse(Version.Split('.')[3]) [= 68571] + │ 9. SqliteDbCreator.CreateDatabaseWithDefinitions(...) [DELETES any existing output DB first + │ (SQLiteDbCreator.cs:11) — every run starts + │ from an empty file — then schema from DBD] + │ 10. HotfixManager.LoadCaches(BaseDir) [best-effort; throw commented out] + │ 11. per table: ApplyingHotfixes; SqliteDataInserter.InsertRows (upsert) + │ + └─ go run tools/database/gen_db/*.go -outDir=./assets -gen=db + reads wowsims.db (dbhelper.go:22) + tools/DB2ToSqlite/listfile.csv (icon map) + + runs tools/database/overrides/{0,1,2}.sql (0.sql/1.sql create item_enchantment_template) + → assets/database/db.{bin,json}, leftover_db.{bin,json} (COMMITTED goldens) +``` + +### 2.2 The four vendored .NET libraries (source not in repo, DLLs only) + +| Library | Upstream | Role in the tool | +|---|---|---| +| TACTSharp | github.com/wowdev/TACTSharp | CASC/TACT client: parse `.build.info`, load build/CDN configs, encoding + root, BLTE-decode, `OpenFileByFDID`; listfile path→FDID (Jenkins96). Has *both* a local-CASC read path (`.idx` + `data.NNN`) and a CDN one — **only the CDN path is exercised here** (BaseDir is never handed to it, §2.1 step 5), including `GroupIndex.Generate` (the four ~120 MB generated group indices in `cache/`). | +| DBCD.IO | github.com/wowdev/DBCD (subproject) | WDC5 binary DB2 decoder + `XFTH` hotfix reader. | +| DBCD | github.com/wowdev/DBCD | Thin orchestration: `Load(table, version)`, `row[col]`, `Values`, `ApplyingHotfixes`. | +| DBDefsLib | github.com/wowdev/WoWDBDefs (`code/C#/DBDefsLib`) | `.dbd` text parser → column/version definitions. | + +Two helpers (`DBCacheParser.cs`, `HotfixManager.cs`) are copied from `github.com/Marlamin/wow.tools.local`. Output SQLite uses NuGet `Microsoft.Data.Sqlite 9.0.3`. + +### 2.3 Downstream Go consumer (unchanged by this migration) + +- `tools/database/dbhelper.go:22` — the *only* reader of `wowsims.db`. +- `tools/database/tables.go` — fixed SQL over named tables + generated `[Col_i]` columns; array base columns parsed as JSON text (`tools/database/utils.go:15` `parseIntArrayField`, `:29` `parseFloatArrayField`). +- `tools/database/icon_loader.go:13` `LoadArtTexturePaths` reads `listfile.csv` (`;`-delimited `FDID;path`), hardcoded at `gen_db/main.go:153`, `gen_protos.go:458`, `tables.go:1123`. +- `tools/database/dbc/spell_scaling.go:12` `//go:embed GameTables/SpellScaling.txt` — a committed copy independent of the extractor run. + +--- + +## 3. Target architecture + +### 3.1 Package layout (single module `github.com/wowsims/mop`, no new module) + +``` +tools/db2tool/ + main.go cobra command (-s/--settings, -o/--output); faithful transcription of Program.cs's 11 steps + config/ settings JSON binding: Settings{Region,Product,BaseDir,BuildConfig,CDNConfig,CacheDir,Locale, + RootMode,ListfileFallback,ListfileURL} + Tables[],GameTables[],GameTablesOutDirectory,TargetDirectory + (CacheDir is bound-but-unused in v1: the local path needs no CDN cache; Phase C would + reintroduce one under tools/db2tool/) + dbd/ .dbd text parser -- BSD-3-Clause (derivative of DBDefsLib) + dbd.go DBDReader.Read + full DBDefinition model (incl. size/isSigned/isNonInline for the WDC5 reader) + select.go exact-build versionDef selection (see §5.5) + wdc/ WDC5 + XFTH decoders -- MIT (derivative of DBCD / DBCD.IO) + bitreader.go byte-exact unaligned little-endian bit reader + wdc5.go header/sections/field-meta/column-meta/pallet/common/idlist/copytable/offsetmap/relationship + section.go per-section iteration + TactKeyLookup!=0 SKIP path (see §7 C1) + row.go DBD-driven field→meta mapping, id-field-offset, trailing-relation refID, sign/float32 reinterpret + hotfix.go Phase D only: XFTH v9 parse + SStrHash + PushId-ordered overlay + tact/ CASC/TACT local read path -- MIT (derivative of TACTSharp); Phase D helpers also cite wow.tools.local + buildinfo.go parse .build.info; select entry by Product; Version.Split('.')[3] → buildNumber + config.go build/CDN config key=value parse (values are `ckey [ekey]`; skip the ~318 `vfs-*` TVFS lines — unused, §10 Q4) + cascidx.go local .idx v7 (bucket XOR + packed archive/offset bits) + dataarchive.go data.NNN via os.ReadAt (no mmap) + 30-byte frame skip + encoding.go EN table (paged BE binary search + 40-bit sizes) + root.go TSFM/MFST WoW root (root CKey -> EKey via encoding); post-10.1.7 dfVersion 1/2; enUS locale + blte.go BLTE N/Z decode (stdlib zlib); F unimplemented (never hit); E chunks left zero-filled (skipped, §7 C1) + (keys.go) OPTIONAL, not in v1: only if you later decrypt pre-release content (Salsa20 + local WoW.txt) + fdid.go static name→FDID map (primary) + optional Jenkins96 + listfile.csv fallback + cdn.go Phase C only: versions/cdns, ranged archive GET, group/file .index + sqlite/ output writer -- original code (repo MIT, no attribution owed) + schema.go SQLiteDbCreator port (deletes any pre-existing output DB first — SQLiteDbCreator.cs:11, §5 lifecycle; + type map, PK, FK IX_ index, array TEXT + [Col_i] VIRTUAL) + insert.go SqliteDataInserter port (upsert, idx_ on relation cols in settings order, JSON arrays; NO relation-0->NULL — that C# path is dead code, §5.4) + internal/golden/ validation harness (schema comparer, per-table row dumper) — see §8 +``` + +**Boundary intent:** `dbd` knows no binary formats; `wdc` depends on `dbd` (types drive decode) but not `tact`; `tact` yields raw bytes and knows no DB2 semantics; `sqlite` consumes decoded rows + DBD metadata. `main.go` is the only meeting point. + +Do **not** merge into `tools/database/dbc` — that package is a *consumer* of `wowsims.db`, not a decoder; there is nothing to share today. A future direct-to-`dbc` refactor is out of scope. + +### 3.2 Reuse-vs-port decision table + +| Component | Decision | Chosen Go lib / port source (license) | Minimal surface covered | +|---|---|---|---| +| SQLite writer | **Reuse** | `modernc.org/sqlite` v1.37.0 (BSD-3, already a dep, no cgo) + stdlib `encoding/json` | schema DDL + upsert insert only | +| `.dbd` parser | **Port** | from WoWDBDefs `code/C#/DBDefsLib` (**BSD-3**) | COLUMNS block + version blocks; exact-build select; 4 types {int,float,locstring,string} + dead-but-keep `uint`; throw on unknown | +| WDC5 record decoder | **Port** | from `wowdev/DBCD` `WDC5Reader`/`BitReader` (**MIT**); `jonathanherbst/model_export` `db2.go` (MIT) as algorithm oracle; `Frostshake/WDBReader` (MIT, C++) cross-check | WDC5 only (all 72 files are WDC5); 6 compression modes; multi-section; sparse/offset-map; copy-table; relationship; **encrypted-section skip** | +| DBCD storage/`Load` | **Port (thin)** | from `wowdev/DBCD` (**MIT**) | `Load` + `row[col]` + `Values` + array materialization; no writers/enums/locale-array/encryption-key | +| Hotfixes | **Defer (no-op v1)** | Phase D: `wowdev/DBCD` `HTFXReader` (MIT) + `wow.tools.local` `DBCacheParser`/`HotfixManager` (verify license) | XFTH v9 + SStrHash + PushId-ordered overlay — only if a concrete gap appears | +| CASC/TACT local read | **Port** | from `wowdev/TACTSharp` (**MIT**); `ladislav-zezula/CascLib` (MIT, C) as `.idx` reference; `erorus/casc` (MIT, PHP) cross-check | `.build.info`, config, `.idx` v7, `data.NNN`, encoding, TSFM root, BLTE N/Z; **ignore TVFS** (`vfs-*` entries are in the build config but unused — §10 Q4), no InstallInstance, no GroupIndex.Generate | +| BLTE | **Port** (part of `tact`) | stdlib `compress/zlib` | N + Z (the only modes needed); F never hit; **no LZ4 mode exists — do not add pierrec/lz4**. 'E' appears only in skipped encrypted sections (§7 C1) — left zero-filled, not decoded in v1 | +| CDN/Ribbit fallback | **Defer (Phase C)** | stdlib `net/http` | optional; only for install-free builds | +| listfile FDID resolution | **Reuse file, replace mechanism** | static `name→FDID` map (primary); Jenkins96 + `listfile.csv` fallback | 80 fixed paths/FDIDs (72 `dbfilesclient/*.db2` + 8 `gametables/*.txt`; 79 unique *names* since `SpellScaling` appears as both); FDIDs stable per path | +| TACT keys + 'E' decrypt | **Skip (not in v1); optional later** | `golang.org/x/crypto/salsa20` + a `WoW.txt` (wowdev/TACTKeys) if ever enabled | The current tool uses no keys and skips every encrypted section (§7 C1); v1 matches that. Build this only if you later want pre-release content — it would add currently-skipped rows and thus **change** output vs today's golden | + +**No importable pure-Go option exists** for CASC/TACT or WDC5. Rejected candidates: `superp00t/gophercraft` (GPL-3.0, non-compiling stub), `lukegb/snowstorm` (cgo, WoD-era root), `jybp/casc` (no LICENSE file → cannot vendor; no WoW root, no WDC), `gtker/wow_dbc` (Rust, classic WDBC only), `erorus/db2` (PHP, tops at WDC3). All are reference-only. + +--- + +## 4. Licensing & attribution + +Ground truth (already confirmed against upstream LICENSE files): + +| Upstream | License | Obligation on our ported files | +|---|---|---| +| TACTSharp | MIT | `tools/db2tool/tact/*.go` carry an upstream MIT copyright/attribution notice header. | +| DBCD + DBCD.IO | MIT | `tools/db2tool/wdc/*.go` carry an upstream MIT copyright/attribution notice header. | +| WoWDBDefs **code** (DBDefsLib, the `.dbd` parser) | **BSD-3-Clause** | A Go translation is a derivative work: `tools/db2tool/dbd/*.go` carry a **BSD-3-Clause** notice + copyright + the non-endorsement clause, and **stay BSD-3** (not relicensed to MIT). BSD-3 is compatible with the MIT repo. | +| WoWDBDefs **data** (`.dbd` files) | **CC BY-SA 4.0** | **Do NOT vendor into the MIT tree.** Keep fetching at build time (see below). | +| wow.tools.local (`DBCacheParser`, `HotfixManager`, SStrHash S-box) | verify before copying | Phase D only; carry upstream notice if ported. | +| **`WoW.txt` TACT keys** (wowdev/TACTKeys) — **only relevant if you opt into decryption (not in v1)** | **NONE (no `LICENSE`; GitHub license API 404 → all-rights-reserved by default)** | The current tool uses no keys, so this is moot for v1. If decryption is ever added: **do NOT vendor** — supply/fetch `WoW.txt` at runtime and resolve the redistribution question (§10 Q11) first. Keys are hex facts, but the repo grants no license. | +| `modernc.org/sqlite` (replaces Microsoft.Data.Sqlite) | BSD-3 | already a dep; no new obligation. | + +Model export / WDBReader oracles are MIT; if a specific algorithm is cross-checked against them, add a one-line note in the relevant `wdc/*.go` header. Pin the exact upstream commit for any oracle used. + +**`.dbd` data handling decision (recommended):** mirror `GithubDBDProvider` — fetch `https://raw.githubusercontent.com/wowdev/WoWDBDefs/master/definitions/.dbd` at build time, cache under a **gitignored** `DBDCache/` with the 24h-mtime rule. This matches the current clean state (verified: `DBDCache/*.dbd` and `listfile.csv` are already gitignored). The extracted game facts that flow into `wowsims.db` are *not* bound by share-alike; the `.dbd` files themselves are. **Fallback if offline/CI reproducibility ever forces vendoring:** isolate the ~72 `.dbd` under a clearly-attributed directory retaining CC BY-SA 4.0 + share-alike notice, do not relicense — a separate, reviewed decision, not part of this port. + +**Concrete NOTICE plan (decide once, before coding, to avoid per-file drift):** + +1. Add `tools/db2tool/NOTICES.md` (or `THIRD_PARTY_NOTICES`) listing each upstream (URL, license, pinned commit) and which package directory derives from it. +2. Standardize a 3–5 line header block per license (one for MIT-attribution, one for BSD-3-with-non-endorsement). Every new file in `dbd/`, `wdc/`, `tact/` opens with the correct block. +3. `sqlite/` and `config/` are original repo code (the schema/insert rules are facts, not a translation) — standard repo MIT, no attribution header needed. +4. Keep `.dbd` fetched-not-committed and `listfile.csv` gitignored, unchanged from today. + +--- + +## 5. The output / schema contract to preserve + +Every rule below is byte-exact-critical for the tables the consumer reads (§5.6); the ~11 "slack" tables must merely extract without error. + +**Run lifecycle (easy to miss — it lives in the helper, not Program.cs):** `CreateDatabaseWithDefinitions` first **deletes the output file if it exists** (`SQLiteDbCreator.cs:11`) — the port must recreate `wowsims.db` from scratch on every run. `CREATE TABLE IF NOT EXISTS` (§5.3) and the upsert (§5.4) therefore only ever see a fresh file — keep the `IF NOT EXISTS` text verbatim anyway, because the §8 step-3 schema gate diffs `sqlite_master` DDL, which contains it. Delete-first is what makes post-patch re-runs and `make db`/`make ptrdb` alternation (shared `CLIENTDATA_OUTPUT`, different builds — §1) correct: without it, upserts never delete removed rows and `IF NOT EXISTS` silently keeps a stale build's schema (e.g. the build-suffixed `Field_*` column names, §5.5). + +### 5.1 Version-definition selection (drives the whole schema) + +Per table: `versionDef = the LAST versionDefinition in file order whose Builds contains a Build with build == buildNumber`, where `buildNumber = uint(Version.Split('.')[3])` **from the live install** (`68571` at analysis time, but it changes on every game patch — §1). **Exact equality**, `builds` only — `buildRanges` and `layoutHashes` are *not* consulted. This must be replicated bug-for-bug (see §7 H1 / §5.5). Because the build moves, the matching `.dbd` for the *current* build must already exist in WoWDBDefs before a run; if not, fail loud (do not fall back to a nearby build — §5.5). + +**Scope caveat — this is the SQLite helpers' rule, and the C# tool actually has *two* selection rules.** The builds-only, trailing-build-number, LAST-match rule above is what `SQLiteDbCreator.cs:35` / `SqliteDataInserter.cs:13` use. The row-decode half (`DBCD.Load`, Program.cs:84) uses a different one: DBDefsLib's `GetVersionDefinitionByBuild` takes the **FIRST** block whose `builds` contains the **full 4-part** version (`5.5.4.68571`) *or* whose `buildRanges` contains it, with a **layoutHash fallback** when no build matches. Today both rules resolve to the same block for every table (verified against all 72 cached `.dbd`: no build listed in two blocks; ranges exist up through 5.4.8 but none contains 5.5.4.68571), so the port's single exact-build selector (`select.go`) is a *deliberate simplification of two C# rules*, not a transcription of one. Keep the fail-loud error when the exact build is absent — that is precisely the case where the C# halves diverge (decode would succeed via range/layoutHash while the helpers NRE on a default-struct `versionDef` at `SQLiteDbCreator.cs:40`). + +### 5.2 Scalar columns (`arrLength == 0`) + +- Type map: `int`/`uint` → `INTEGER`; `float` → `REAL`; `string`/`locstring` → `TEXT`. **Throw on anything else** (matches `MapToSqLiteType`). +- `[Name] `. Append ` NULL` iff the column has both `foreignTable` and `foreignColumn` set **and** is not the ID column. Append ` PRIMARY KEY` iff `isID`. +- If FK (both foreign fields set): also emit `CREATE INDEX IF NOT EXISTS IX_
_ ON [
] ([])`. +- `locstring` maps to a single `_lang` TEXT column named verbatim from the DBD (`Display_lang`, `Name_lang`, `HordeName_lang`, …). **No suffix synthesis, no locale array** — this is settled by the DBD, not open. + +### 5.3 Array columns (`arrLength > 0`) + +- One real column `[Name] TEXT` holding a JSON array. +- Plus, for `i` in `0..arrLength-1`, a generated column: + `[Name_i] GENERATED ALWAYS AS (json_extract([Name], '$[i]')) VIRTUAL` + where `` is the type map applied to the base type. +- `CREATE TABLE IF NOT EXISTS [
] (...)`; then FK index statements. `PRAGMA foreign_keys = ON;` is set for parity but no `FOREIGN KEY` constraints are emitted — only indexes. + +### 5.4 Inserts + +- Preserve `versionDef.definitions` column order. +- Upsert: `INSERT INTO [
] ([c1],...) VALUES (@c1,...) ON CONFLICT([pk]) DO UPDATE SET [c]=excluded.[c] ...` for every non-PK column; if only the PK exists → `DO NOTHING`. +- For each `isRelation` column: `CREATE INDEX IF NOT EXISTS idx_ ON
();`. **The index name omits the table**, so with `IF NOT EXISTS` only the *first* table processed with a given relation-column name gets the index (reference DB has exactly 11 `idx_*`; e.g. `idx_spellid` lands on `SpellMisc` only, by settings order). The port must create indexes in settings-`Tables[]` order with the same table-less names — and must **not** iterate tables via a Go map anywhere (also §7 M3). +- Per row: `NULL` for missing values; array values JSON-serialized as `[a,b,c]`. **Do NOT convert relation-0 to NULL:** the C# `if (colDef.isRelation && value == (object)0)` (`SqliteDataInserter.cs:78`) is a *boxed reference comparison* that is always false — dead code. The reference DB keeps 0s (e.g. `ItemSubClass.ClassID` has 9 zero rows, no NULLs), so the port must insert 0 as 0. Replicating the *apparent* intent would break row parity on `ItemSubClass` / `ItemUpgrade` / `ItemNameDescription` (all critical). + +### 5.5 Array-serialization hazards the consumer enforces (verified in `tools/database/utils.go`) + +- `parseIntArrayField` (`utils.go:15`) returns `nil,nil` on `""`, else unmarshals and **errors unless `len == expectedLen`**. Used on many base TEXT columns (non-exhaustive): `EffectMiscValue`(2), `EffectSpellClassMask`(4), `ImplicitTarget`(2), `ItemSparse.StatModifier_bonusStat`→`BonusStat`(10) / `SocketType`→`Sockets`(3), `SpellItemEnchantment` triplets, and `Spell*` masks incl. a 17-length `Attributes`. +- `parseFloatArrayField` (`utils.go:29`) has **no empty-string guard** — `""` → error. Used on `StatPercentageOfSocket`(10), `Field_1_15_3_55112_014`→`StatAlloc`(10), `StatModifier_bonusAmount`→`BonusAmountCalculated`(10), `StatPercentEditor`→`SocketModifier`(10), `ItemDamage*.Quality`(7). + +**Consequence:** array columns must serialize as a JSON array with **exactly `arrLength` elements**, matching what C# `JsonSerializer.Serialize` emits for an all-zero/empty array (i.e. `[0,0,...]`, not `NULL`/`[]`/`""`). Confirm the C# emission and match it byte-for-byte, or `make db` dies in the consumer. + +**Float precision (load-bearing):** DBD `type=="float"` fields must be decoded and marshaled as **`float32`**, not `float64`. Go's `encoding/json` prints 32-bit-precise text only for a `float32` value; an upcast makes `0.1f` become `0.10000000149011612` in both the JSON text and the `json_extract` REAL column — silently wrong item stats. **Scalar** float REAL columns differ but come out right: binding a `float32` stores the double-widened value (verified: `SpellProcsPerMinute.BaseProcRate` = `0.5809999704360962`), which `database/sql` reproduces automatically — so bind scalars as `float32` and let widening happen; don't format either side as text. + +**Float *notation* divergence (verified — `float32` + raw `json.Marshal` is NOT full text parity):** .NET's shortest-round-trip formatter switches to scientific notation (uppercase `E`, two-digit zero-padded exponent) for `|v| < 1e-4` or `>= 1e15`; Go's `encoding/json` does so only for `|v| < 1e-6` or `>= 1e21` (lowercase `e`, unpadded). Already observable in real data: `CurvePoint` Id=236585 stores `Pos = "[1,-6E-05]"` (reference) where Go emits `"[1,-0.00006]"` — the only E-notation values in the entire DB today, and `CurvePoint` is slack (§5.6) with no reader under `tools/database`. **Resolution:** keep raw `json.Marshal`, scope array-text byte parity to the critical set (§5.6, §8 step 4), and add a harness assertion that no *critical*-table float array element falls in the divergent ranges `[1e-6,1e-4)` / `[1e15,1e21)` — if that assertion ever fires, implement a small .NET-compatible float32-to-text formatter for JSON array elements instead. **String-array escaping (future-proofing):** no string/locstring *arrays* exist in the 68571 blocks, but if one ever appears, C# `JsonSerializer` escapes non-ASCII/HTML as uppercase `<` while Go emits lowercase `<` / raw UTF-8 — a byte-parity trap to handle then. + +**Schema shape is build-dependent, not just values:** `ItemRandomSuffix.dbd` defines `AllocationPct` as `<32>[3]` in one build block and `[5]`/`<32>[5]` in others. The consumer reads `AllocationPct_0..AllocationPct_4` (5 virtual columns); a wrong version block yields `arrLength=3` → columns `AllocationPct_3/_4` don't exist → `tables.go` fails "no such column". This is why exact-build match must be replicated and why "nearest ≤ build" is unsafe. On no match, fail loud (not nil-panic). + +### 5.6 Byte-exact-critical vs slack tables + +**Critical (row + value parity required):** `Item`, `ItemSparse`, `SpellEffect`, `SpellItemEnchantment`, `ItemRandomSuffix`, `RandPropPoints`, `SpellMisc`, all 8 `ItemDamage*`, `ItemArmorQuality/Shield/Total`, `ArmorLocation`, `GemProperties`, `ItemEffect`, `ItemClass`, `ItemSubClass`, `ItemSet`, `ItemNameDescription`, `RulesetItemUpgrade`, `ItemUpgrade`, `Spell` + the large joined set (`SpellName`, `SpellLevels`, `SpellCooldowns`, `SpellScaling`, `SpellLabel`, `SpellCategories`, `SpellCategory`, `SpellDuration`, `SpellPower`, `SpellInterrupts`, `SpellEquippedItems`, `SpellAuraOptions`, `SpellClassOptions`, `SpellShapeshift`, `SpellXDescriptionVariables`, `SpellDescriptionVariables`, `SpellTargetRestrictions`, `SpellRange`, `SpellRadius`, `SpellProcsPerMinute`, `SpellProcsPerMinuteMod`), `GlyphProperties`, `SkillLineAbility`, `Talent`, `Faction`, `Map`, `JournalEncounter/EncounterItem/Instance`, `AreaTable`. + +**Slack (extract without error; schema/values not in today's acceptance test):** `ItemSetSpell`, `ItemSubClassMask`, `ItemReforge`, `ItemBonus`, `ItemRandomProperties`, `ItemExtendedCost`, `Curve`, `CurvePoint`, `ScalingStatDistribution`, `SpellReagents`, `SpellMechanic` (and, subject to grep-caveat, `Difficulty`, `TalentTab`, `SkillLine`). + +### 5.7 The other two outputs + +- **8 basestats `.txt`** written verbatim; **filename casing preserved from settings** (`chancetomeleecrit`, `chancetomeleecritbase`, `chancetospellcrit`, `chancetospellcritbase`, `combatratings`, `octbasempbyclass`, `OCTBaseHPByClass`, `SpellScaling`). `SpellScaling.txt` additionally exists as a committed `//go:embed` at `tools/database/dbc/GameTables/SpellScaling.txt` — independent of the run; document that it is not auto-synced. +- **`listfile.csv`** must still exist at the path the consumer expects (see §9 for the repoint decision). +- **`item_enchantment_template`** is created by `tools/database/overrides/{0,1}.sql` (`RunOverrides`, `dbhelper.go:63`), **not** by DB2 — the port must not fold it in. + +--- + +## 6. Phased implementation plan + +Each phase ends with a concrete golden-diff gate (§8) before the next begins. The validation assets are two references re-captured per build by today's dotnet tool on a WoW-installed machine — `wowsims.nohotfix.db` (gates A/B) and `wowsims.hotfix.db` (gates D) — plus the `dbfilesclient/*.db2` and `DBDCache/*.dbd` it drops (§8 step 1, §10 Q5). + +### Phase A — `.dbd` parser + WDC5 decoder + SQLite writer (fed by pre-extracted `.db2`) + +**Scope.** Everything downstream of file extraction, decoupled from CASC: +- `dbd`: full parser + exact-build `versionDef` selection (§5.1), complete data model (incl. `size`/`isSigned`/`isNonInline` for the reader). Strip UTF-8 BOM; handle CRLF; hard-error on a version-field name absent from COLUMNS. +- `wdc`: WDC5 reader — header (magic assert `"WDC5"`; fail loud on WDC6+), **multi-section iteration** (only 51/72 are single-section; core tables have 26–36), all 6 compression modes (None / Immediate / SignedImmediate / Common / Pallet / PalletArray), id-list, copy-table, sparse/offset-map inline strings (**`Spell` and `ItemSparse` are both `Flags=0x5` = Sparse(0x1)|Index(0x4)**, i.e. sparse + non-inline id list; per DBCD `DB2Flags`, `SecondaryKey` (0x2) is *not* set on any table here), relationship/parent-lookup trailing FK columns, negative-base string-offset resolution, and the **encrypted-section skip path** (§7 C1). No hotfixes. +- `sqlite`: schema creator + inserter (§5.2–§5.5), incl. `float32` marshaling and strict-length arrays. +- Temporary driver: read `.db2` from `tools/DB2ToSqlite/dbfilesclient/` and `.dbd` from `DBDCache/`; `buildNumber` passed as a flag. + +**Key tasks:** bit reader (byte-exact unaligned LE load + shift pair); section skip + copy-of-skipped-source drop; DBD field→meta index mapping (id-field-offset, `fieldIndex >= Meta.Length` → refID); JSON array shaping to match C#. + +**Exit criteria.** +1. **Schema parity:** all 72 tables — `sqlite_master` DDL (whitespace-normalized) identical to reference, including PK / `NULL` / `IX_*` / `idx_*` / `[Col_i]` VIRTUAL count and DBD-derived names (`Field_1_15_3_55112_014`, `Field_1_15_7_59706_054`). +2. **Row parity:** every critical table (§5.6) — `SELECT * ORDER BY ` canonical dump equals reference; **row counts checked first** as a cheap tripwire (encrypted-skip makes this non-trivial — see §7 C1). Slack tables must extract without error only. +3. **End-to-end:** `go run tools/database/gen_db/*.go -outDir=./assets -gen=db` against the Go DB yields **byte-identical** `assets/database/db.json` / `leftover_db.json` vs the reference DB (control ordering per §7/§8; run gen_db against *copies* — it mutates its input via `RunOverrides`, §8 step 1). This is the true acceptance test. + +**Effort: L** (dbd = S, sqlite = S, WDC5 decoder = L and dominates; multi-section + sparse + encrypted-skip on the core tables is the critical path). +**Dependencies:** none (uses pre-extracted artifacts). Proves the correctness core with zero CASC code. + +### Phase B — Local CASC/TACT extraction (a deliberate behavior change: the current tool is CDN-fed, §2.1 step 5) + +**Scope.** Replace the pre-extracted-`.db2` driver with real extraction from a local install. Note this is *new* behavior, not a transcription — the .NET tool fetches everything from the CDN (revision note, §1 Stance); the local path is chosen because it is simpler (no HTTP, no group-index generation, no 1.2 GB cache) and because gate 1 gives an exact oracle. Components: +- `tact`: `.build.info` parse + entry-by-Product + `Version.Split('.')[3]`→`buildNumber`; build/CDN config parse; local `.idx` v7 (bucket XOR, packed offset/archive bits, +30 frame); `data.NNN` via `os.ReadAt`; EN encoding table; TSFM root (post-10.1.7 dfVersion 1/2, enUS); **BLTE N/Z only** (encrypted 'E' chunks stay zero-filled, so the WDC layer skips those sections — matching the keyless .NET tool; §7 C1). No Salsa20 / `WoW.txt` in v1. +- `OpenFileByFDID`: FDID → root CKey → encoding EKey → local `.idx` → `data.NNN` → BLTE. +- FDID: static `name→FDID` map (primary) + optional Jenkins96 + `listfile.csv` fallback. +- GameTables extraction: open `gametables/.txt` by FDID, write raw bytes to `GameTablesOutDirectory`, **preserving filename casing**. +- **`listfile.csv` contract fix** (§9): repoint the three hardcoded literals in the same change. +- **CWD/relative-path fix** (§7 M4): resolve `GameTablesOutDirectory`, `TargetDirectory`, `DBDCache/`, `listfile.csv` relative to the settings file (or repo root), not the process CWD. +- **HTTP fetchers (the current tool does these every run — they are NOT CASC/CDN and belong here, not Phase C):** (a) fetch each `.dbd` from `https://raw.githubusercontent.com/wowdev/WoWDBDefs/master/definitions/
.dbd` into the gitignored `DBDCache/` with a 24h-mtime refresh (mirrors `GithubDBDProvider`); (b) fetch/refresh `listfile.csv` from `ListfileURL` (HEAD + Last-Modified, honoring `ListfileFallback`). Without these, a fresh machine — or any machine after a game patch (new-build `.dbd` mandatory per H1; listfile gains new FDIDs) — cannot run `make db`. +- Wire `makefile` (§9). + +**Exit criteria.** +1. On a machine with a real `wow_classic` install, the tool produces a `wowsims.db` passing all Phase A criteria; first gate is a per-table diff of the extracted `.db2` bytes vs the vendored `dbfilesclient/*.db2` (a free exact oracle). +2. 8 basestats `.txt` byte-identical to the committed files. +3. `make db` runs end-to-end **with no dotnet installed** → byte-identical `db.json` / `leftover_db.json`. +4. Fail-loud (not nil-panic) when: a table's build isn't in the `.dbd`; `BaseDir` is missing; a needed FDID isn't in the local `.idx`. + +**Effort: L** (~6 byte-exact parsers; local-first avoids CDN). +**Dependencies:** Phase A. **Pre-flight:** the live install's local CASC files are verified *present and well-formed* (`5.5.4.68571`): the build config has a usable WoW `root` (TVFS coexists but is unused — skip `vfs-*` lines, §10 Q4); 16 `.idx` buckets (v7 confirmed: 9-byte keys, 30-bit offsets), 27 `data.NNN` (~1 GB each), `config/` (build+cdn), prebuilt `indices/*.index`; Install Key + KeyRing are empty in `.build.info` (no InstallInstance, no keyring). **But the current tool never reads any of it** (§2.1 step 5), so the load-bearing precondition — *every needed FDID resolves through the local `.idx` to a resident `data.NNN` chunk* (only the unencrypted section 0 matters; the rest are skipped encrypted sections, §7 C1) — is **unproven**. Prove it before writing Go: apply the one-line dotnet patch (`buildInstance.Settings.BaseDir = settings.BaseDir;` after Program.cs:41), run the tool, and byte-diff the extracted `.db2`/gametables against a CDN-fed run's (§1 Stance). That also pins the exact TSFM `dfVersion` question (§10 Q4) against the local root. In the ported local path, `GroupIndex.Generate` and InstallInstance are never needed — fail loud if hit (the as-is CDN tool *does* generate group indices; that code is not ported). Encrypted DB2 sections are BLTE-'E' chunks; leaving them zero-filled (no keys) reproduces today's output (§7 C1), so v1 needs no key handling. + +### Phase C — CDN/Ribbit fallback (optional; only if install-free builds are wanted) + +**Scope.** `tact/cdn.go`: patch-service `versions`/`cdns`, host selection, config-by-hash fetch, ranged archive GET, group/file `.index` footer/TOC binary search. (The `.dbd` and `listfile.csv` HTTP fetches live in Phase B, not here — they are plain GETs the tool always needs, independent of CASC/CDN.) + +**Exit criteria.** With `BaseDir` empty/incomplete, the tool downloads missing pieces and still produces a Phase-A-passing DB; `make db` succeeds with no WoW install. + +**Effort: L–XL** (largest networking surface, incl. `GroupIndex.Generate` — the as-is tool builds four ~120 MB group indices). **Explicitly optional, but note the framing:** the CDN path is what the *current* tool actually uses (§2.1 step 5), so porting it is the strict-parity route; it is deferred anyway because the local path is smaller and Phase B gate 1 proves byte equivalence. Pursue Phase C only for CI/install-free builds — or promote it if the Phase B pre-flight ever shows local extraction incomplete. +**Dependencies:** Phase B. + +### Phase D — Hotfixes (REQUIRED for parity with the committed `db.json`) + +**Scope.** `wdc/hotfix.go`: XFTH v9 header + 28-byte entry parse (the entry follows a 4-byte per-record `XFTH` magic); SStrHash (verbatim 16-entry S-box) `name→tableHash`; byte-aligned sequential blob decode driven by the same DBD field metadata + non-inline-ID rule; `CombineCache` dedup; add/delete overlay (verified against DBCD.IO 2.1.2: `ReadHotfixes` applies records in an explicit stable ascending-PushId sort — LINQ `orderby x.PushId`, file/combine insertion order preserved within a PushId. Row ops via `DefaultProcessor`: Add iff `IsValid && DataSize > 0`, else Delete when `shouldDelete`, else Ignore; `shouldDelete` is false only for tableHash `0xDF2F53CF` (TactKey) and `0x021826BB` (BroadcastText) while a valid PushId == -1 record with data exists — neither table is in our `Tables[]`, so for this port `shouldDelete` is always true and the rule collapses to the plain add/delete overlay. `Combine` dedups via `HashSet` whose `GetHashCode` also hashes the record's *data bytes* — port dedup as full-record identity (PushId, TableHash, RecordId, IsValid, DataSize, **plus data bytes**), not the 5-tuple alone); exact `buildNumber` match. `HotfixManager.LoadCaches` scans `caches/*.bin` + `/**/DBCache.bin`. `knownPushIDs.json` (untracked, written by `HotfixManager`) is logging-only — **do not reproduce it**. In Phases A–C this is a **no-op stub**, but it is **not optional overall**: the maintainer confirmed the committed `db.json` is generated **with** hotfixes applied from the local client's `DBCache.bin`. To reproduce the shipped artifacts, the port must apply them too. + +**Exit criteria.** A run *with* the same `DBCache.bin` matches the committed (with-hotfix) `db.json`; a run *without* matches the un-hotfixed reference used to gate Phases A/B (§8). Also quantify which sim-read fields hotfixes actually touch, to bound risk. + +**Effort: M** (S for hotfix-specific code; rides the Phase A field-metadata layer). +**Dependencies:** Phase A (field metadata). **Sequencing note:** because the end-to-end golden gate (§8 step 6) diffs the *committed, with-hotfix* `db.json`, that gate cannot fully pass until D lands — Phases A/B must gate against a freshly-regenerated *without-hotfix* reference instead (§8). + +**Net recommendation:** execute **A → B → D** (D is required for committed-artifact parity, though it can land last since A/B gate against a without-hotfix reference); treat **C** as opt-in. That removes dotnet from `make db`/`make ptrdb` for the real workflow while proving correctness at every step. + +--- + +## 7. Risks & blockers + +Risk IDs are historical labels kept stable for cross-references; the **Sev** column is authoritative (C1 was downgraded to High and M5 to Low after verification; M6 was upgraded and renamed H4). + +| ID | Sev | Risk | Mitigation | +|---|---|---|---| +| C1 | High | **Encrypted DB2 sections are skipped — the tool uses NO TACT keys (VERIFIED against the vendored `.db2`).** Most core tables have 35 encrypted sections of 36 (`TactKeyLookup != 0` — `Item`/`Spell`/`SpellEffect`/`SpellMisc`/`ItemEffect`/`SpellName`; `ItemSparse` is 25 of 26; section 0 is always the sole unencrypted section), and in the extracted files **all encrypted sections are zero-filled** → DBCD skips them. This is a small amount of pre-release content: per-section counts are tiny (mostly 1–41). Exact check: `SpellEffect` header `record_count` 142756 − 136 encrypted rows = **142620, the DB row count**. The committed `db.json` is this keyless-skip result. Consequences: header `record_count` ≠ emitted rows; copy-table entries whose source is in a skipped section are dropped. | Port DBCD's exact skip test — `TactKeyLookup != 0` **and** the section's record-data all-zero (WDC5Reader also guards first id-list value 0 / first sparse-entry size 0) → skip the section and its copies — a small, well-defined path, **no crypto**. Never derive expected row count from header `record_count`. Golden-diff row counts table-by-table (§8). Decryption is an optional future enhancement (§3.2/§4) that *changes* output — out of scope for a parity port. | +| C2 | **Critical** | **WDC5 decoder correctness.** No importable pure-Go reader; must be byte-exact across ~50 critical tables. Multi-section (26–36 on core tables), sparse offset-map (`Spell` + `ItemSparse` both `Flags=0x5`), 6 compression modes, trailing non-inline relations, negative-base string offsets, and **sign/float32 reinterpretation** each silently corrupt on a single off-by-one. | Golden-diff every critical table vs reference (Phase A). DBCD `WDC5Reader`/`BitReader` as exact spec; `model_export/db2.go` as oracle. WDC5-only (no version matrix). Tolerate 0-section/0-record files (`ItemBonus.db2` is empty). | +| C3 | **Critical** | **Local CASC/TACT read path.** ~6 byte-exact parsers (`.idx` bucket XOR + packed bits, `data.NNN` 30-byte frame, EN 40-bit BE sizes, TSFM dfVersion 1/2 root, BLTE N/Z); any failure yields empty/garbage with no crash. The live install's files are verified *present and well-formed* — `.idx` v7 (9-byte keys, 30-bit offsets), 16 buckets, 27 `data.NNN`, build+cdn configs, prebuilt `indices/`; `.build.info` Install Key + KeyRing empty — **but the current tool never reads them** (§2.1 step 5): the local path is *new behavior*, and its precondition (every needed FDID resident in local archives) is unproven until the Phase B pre-flight. | Prove residency first via the one-line dotnet BaseDir patch + byte-diff (§6 Phase B pre-flight); then diff Go-extracted `.db2` bytes vs vendored `dbfilesclient/*.db2` (free exact oracle). stdlib primitives; `os.ReadAt` not mmap. Skip `vfs-*` config lines (TVFS present but unused, §10 Q4). In the *ported local path*, fail loud if `GroupIndex.Generate` / InstallInstance are ever hit (the as-is CDN tool does run `GroupIndex.Generate` — that code is not ported). | +| H1 | High | **Exact-build `.dbd` match fragility — a recurring operational reality, not a one-off (CORRECTED — the build is not pinned; the tool tracks the live game).** `versionDefinitions.LastOrDefault(v => v.builds.Any(b => b.build == ))`; every game patch changes the build, so each run depends on WoWDBDefs *already* containing that build. Schema (incl. `Field_*` names, `AllocationPct` arrLength) derives from that block; relaxing the rule silently changes the contract (§5.5). | Replicate exact-match bug-for-bug; emit a **clear error** ("build N not in WoWDBDefs yet — wait for upstream"), not a nil-panic, on no match. Refresh `.dbd` per run. Any range fallback is a separate, reviewed change. | +| H2 | High | **`listfile.csv` second contract.** Hardcoded at `gen_db/main.go:153`, `gen_protos.go:458`, `tables.go:1123`; 148 MB; also needed for extractor FDID lookup. Easy to overlook since it's not the `.db`. | Repoint all three literals in Phase B (§9). Static FDID map removes the download *from extraction*, but the icon map still needs the full listfile — keep producing/caching it. | +| H3 | High | **Float32 precision** (§5.5). `float64` marshaling silently corrupts item stats in `db.json`. | Decode/marshal DBD `float` as `float32`; spot-check anchored IDs incl. float values (§8). | +| M1 | Med | **CI without a WoW install.** `make db` needs a ~100 GB local install; no CI runner has it. *Not a regression* — the dotnet tool has the same constraint and outputs are committed, so CI never runs `make db`. Consequence: the port is validated on a maintainer machine, not CI. | Keep `make db` maintainer-run. Phase A validates offline against vendored `.db2`; Phase B against vendored `.db2` outputs. | +| M2 | Med | **Byte-diffing the `.db` file fails spuriously.** Microsoft.Data.Sqlite vs modernc differ in SQLite version, page size, journal/encoding PRAGMAs, rowid/freelist. | Validate *logically* — schema DDL + per-table `ORDER BY pk` dumps + `db.json` text diff (§8). Never MD5 the `.db`. | +| M3 | Med | **Row/section ordering vs the committed `db.json`.** Section iteration / copy-table / Go map order can produce a logically-equal DB but a different (committed, text) `db.json`. | Guarantee deterministic order matching C#, or make the `db.json` gate order-insensitive; don't assume `git diff db.json == 0` without controlling order. | +| M4 | Med | **CWD / relative-path contract.** `make db` does `cd tools/DB2ToSqlite && dotnet run`; `GameTablesOutDirectory="../../assets/db_inputs/basestats"`, `TargetDirectory`, `DBDCache/`, `listfile.csv` all resolve relative to that dir. `go run ./tools/db2tool` from repo root resolves `../../...` above the repo. | Resolve these paths relative to the settings file (or repo root) in `config`/`main.go`; re-pin every relative base deliberately in Phase B. **Carve-out:** `TargetDirectory` is used *twice* in Program.cs (lines 77-78) — resolve only the on-disk output-directory use; any Jenkins96/listfile FDID lookup (the §3.2 fallback) must key on the raw game path `dbfilesclient/NAME.db2` (the unresolved settings value), never a resolved filesystem path — a resolved path hashes to a listfile miss (`GetFDID` → 0) and `OpenFileByFDID` throws "File not found in root". The primary static name→FDID map (§3.2) is immune. | +| M5 | Low | **BLTE 'E' decode is NOT needed for parity (VERIFIED — the tool runs keyless).** Encrypted chunks arrive zero-filled and their sections are skipped; N + Z cover every other chunk. | Implement N + Z only; leave 'E' chunks zero (do not error). F never occurs; ARC4 ('A') not needed. Salsa20 + `WoW.txt` is an optional enhancement (§3.2), deliberately out of v1 parity scope. | +| H4 | High | **Hotfixes are applied in the committed output (CORRECTED — maintainer confirmed `db.json` is generated WITH hotfixes).** DBCache content is machine/time-dependent, but the shipped artifacts include it, so a faithful port must apply hotfixes to reach parity. | Phase D is **required** (not a permanent stub). Gate Phases A/B against a freshly-regenerated *without-hotfix* reference; gate the final committed `db.json` only after D. Quantify which sim-read fields hotfixes touch to bound scope. | +| L1 | Low | **Licensing correctness.** | Per-file headers + `NOTICES.md` (§4); `.dbd` fetched-not-vendored; `listfile.csv` gitignored. `WoW.txt`/TACTKeys (no license) is only a concern if the optional decrypt path is ever built (§4, §10 Q11) — not for v1. | +| L2 | Low | **no-cgo** already satisfied; `modernc.org/sqlite` verified for VIRTUAL cols + `json_extract` + `ON CONFLICT` + `PRAGMA foreign_keys`. | Keep the port cgo-free; no `pierrec/lz4` (no LZ4 mode exists). | + +--- + +## 8. Validation strategy + +Byte-diffing the `.db` will fail spuriously (M2). Validate in a layered ladder; build the harness once (`tools/db2tool/internal/golden/`) and reuse it every phase. + +1. **Capture a reference (re-captured on every game patch/hotfix — it is not permanently frozen; maintainer-owned).** Run today's dotnet tool on the *current* live build; save two references: **`wowsims.nohotfix.db`** (no `DBCache.bin`) to gate Phases A/B, and **`wowsims.hotfix.db`** (with the client's `DBCache.bin`, matching how the committed `db.json` is produced) to gate Phase D. Also keep that run's `dbfilesclient/*.db2` and `DBDCache/*.dbd` so the WDC/DBD layers validate offline, decoupled from CASC. The 72 `.db2` + 72 `.dbd` already present in the repo working copy are exactly such a snapshot. **Producing the `nohotfix` capture:** the dotnet tool has no disable switch (`HotfixManager.LoadCaches` auto-scans `/**/DBCache.bin`), so capture it by temporarily moving/renaming the client's `DBCache.bin` files (or a one-line local patch to skip `LoadCaches`) before that run. **Keep the captured references pristine:** gen_db *mutates* the DB it opens — `RunOverrides` (`dbhelper.go:63`, called at `gen_db/main.go:66`) creates/populates `item_enchantment_template` in it — so always run end-to-end gates against disposable *copies* of both the Go-produced DB and the reference (place the copy at the default `-dbPath ./tools/database/wowsims.db` or pass `-dbPath` explicitly). +2. **Pre-port audit (no code needed; inputs already on disk).** Dump per-table `SectionsCount`, per-section `TactKeyLookup`, `Flags`, and DBD `arrLength`/type for all 72 `.db2`+`.dbd`; freeze as the reader's expectation fixture. (Seed values: the *selected* build-68571 version blocks — what the reader actually sees — total int 515 / float 65 / locstring 43 / string 5, no `uint`. The larger int 786 / float 91 / locstring 50 / string 8 are all-builds COLUMNS totals across the `.dbd`, **not** per-build — don't use those as the fixture. Distinct section counts 36/33/26/22/16/9/8/3/2; `ItemBonus` empty; `Spell`/`ItemSparse` `Flags=0x5`.) +3. **Schema parity.** Compare sorted `sqlite_master` (CREATE TABLE + indexes, whitespace-normalized) reference vs Go. Catches column names/order, PK/NULL, the `[Name] TEXT` + `[Name_i] ... GENERATED ALWAYS AS (json_extract(...)) VIRTUAL` set, `IX_*`/`idx_*`, and — critically — `arrLength`-derived virtual-column counts (`AllocationPct_0..4`, §5.5). +4. **Logical row parity (not byte).** Every table: assert **row counts** first (cheap tripwire, decisive for the encrypted-skip tables in C1). Then `SELECT * ORDER BY ` canonical dump equality for the **critical set (§5.6) only** — matching Phase A exit criterion 2; slack-table text diffs are reported informationally, not gating (known expected divergence: `CurvePoint` Id=236585 float notation, §5.5). Explicitly check that relation columns **keep 0** (the C# 0→NULL is dead code — §5.4; spot-check `ItemSubClass.ClassID = 0`) and the array-JSON text shape (§5.5), incl. the no-divergent-float-magnitude assertion from §5.5. +5. **Spot-check anchored IDs.** Pin known sim-relied IDs that exist in *this* DB and assert exact values incl. float precision: an `ItemSparse` row with its `Field_1_15_3_55112_014` stat array; a spell in `SpellEffect` with `EffectMiscValue`/`EffectSpellClassMask`; an `ItemRandomSuffix` row (e.g. `[6666,10000,0,0,0]`) and an `ItemArmorQuality.Qualitymod` array. (Do **not** use `146051` — verified absent from this MoP DB.) +6. **End-to-end golden (the real test).** Run full `make db` with the Go extractor **against the same live install + `DBCache.bin` the committed artifacts were built from (with hotfixes — §6 Phase D)**, then `git diff --exit-code` on the **committed** artifacts: `assets/database/db.json`, `assets/database/leftover_db.json` (text, diffable), the regenerated `.bin` files, and `assets/db_inputs/basestats/*.txt`. Control ordering (M3) or diff order-insensitively. `db.json` is what ships — this proves the whole contract, since there is **no committed golden `wowsims.db`** (it's gitignored). This gate needs Phase D; before D, gate against the without-hotfix reference (step 1). +7. **modernc smoke test (committed).** A small Go test that creates the §5.2/§5.3 schema shapes, upserts via `@name` params, and reads back `json_extract` virtual columns (int *and* float), NULL scans, and REAL vs INTEGER marshaling — so driver-marshaling parity is a permanent regression gate, not a one-off. + +--- + +## 9. Makefile / dev-workflow changes & dotnet-removal cleanup + +### 9.1 Makefile (targets and var names unchanged) + +Current (`makefile:245-261`): + +```make +CLIENTDATA_SETTINGS := $(shell realpath ./tools/database/generator-settings.json) +CLIENTDATAPTR_SETTINGS := $(shell realpath ./tools/database/ptr-generator-settings.json) +CLIENTDATA_OUTPUT := $(shell realpath ./tools/database/wowsims.db) + +.PHONY: db +db: + @echo "Running DB2ToSqlite for clientdata" + cd tools/DB2ToSqlite && dotnet run -- -s $(CLIENTDATA_SETTINGS) --output $(CLIENTDATA_OUTPUT) + @echo "Running DBC generation tool" + go run tools/database/gen_db/*.go -outDir=./assets -gen=db + +.PHONY: ptrdb +ptrdb: + @echo "Running DB2ToSqlite for clientdata" + cd tools/DB2ToSqlite && dotnet run -- -s $(CLIENTDATAPTR_SETTINGS) --output $(CLIENTDATA_OUTPUT) + @echo "Running DBC generation tool" + go run tools/database/gen_db/*.go -outDir=./assets -gen=db +``` + +Target (keep the `.PHONY` declarations — a repo-root file named `db`/`ptrdb` would otherwise make the targets report up-to-date): + +```make +.PHONY: db +db: + @echo "Extracting client data (pure Go)" + go run ./tools/db2tool -s $(CLIENTDATA_SETTINGS) --output $(CLIENTDATA_OUTPUT) + @echo "Running DBC generation tool" + go run tools/database/gen_db/*.go -outDir=./assets -gen=db + +.PHONY: ptrdb +ptrdb: + @echo "Extracting client data (pure Go)" + go run ./tools/db2tool -s $(CLIENTDATAPTR_SETTINGS) --output $(CLIENTDATA_OUTPUT) + @echo "Running DBC generation tool" + go run tools/database/gen_db/*.go -outDir=./assets -gen=db +``` + +Note the removal of `cd tools/DB2ToSqlite`: the Go tool must resolve `GameTablesOutDirectory` / `TargetDirectory` / `DBDCache` / `listfile.csv` relative to the settings file or repo root (M4), since `go run ./tools/db2tool` executes from repo root — but observe the M4 carve-out: `TargetDirectory`'s *listfile-key* use stays the raw settings value (`dbfilesclient/...`), only its output-directory use is resolved. Keep the `-s` / `--output` flag contract (also accept the single-dash `-output` and `-o` aliases, as Program.cs does); the settings `DatabaseFile` key is **dead code** — Program.cs never reads it (§10 Q7), so don't implement it. + +### 9.2 `listfile.csv` path repoint (do this in Phase B) + +Recommended: write `listfile.csv` to `tools/db2tool/listfile.csv` and repoint the three consumers: + +- `tools/database/gen_db/main.go:153` +- `tools/database/gen_protos.go:458` +- `tools/database/tables.go:1123` + +(Alternative: keep writing to `tools/DB2ToSqlite/listfile.csv` and leave the literals — but that resurrects the deleted directory as a data dir. Repointing is cleaner.) Keep `listfile.csv` gitignored under its new location. + +### 9.3 dotnet-removal cleanup (after Phase B passes) + +- Delete `tools/DB2ToSqlite/references/*.dll` (TACTSharp, DBCD, DBCD.IO, DBDefsLib). +- Delete `tools/DB2ToSqlite/cache/` — TACTSharp's CDN cache (~1.2 GB, `tpr/` + `wow/` layout, gitignored). The Go v1 has no use for it: the local-install path needs no CDN cache (`CacheDir` is bound-but-unused, §3.1); only Phase C would reintroduce one, under `tools/db2tool/`. +- Delete `tools/DB2ToSqlite/*.csproj`, `Program.cs`, `Helpers/`, the copied `DBCacheParser.cs` / `HotfixManager.cs`, `appsettings.json`, `appsettings.Development.json`, `Properties/launchSettings.json`, `.vscode/launch.json`, `knownPushIDs.json`, and the `obj/` / `bin/` build dirs. +- Remove the `DB2ToSqlite` project from the `.sln` (and delete the `.sln` if it has no other projects). +- Preserve or relocate build-artifact dirs still referenced: `dbfilesclient/`, `DBDCache/`, `listfile.csv` move under `tools/db2tool/` (all gitignored). Migrate the relevant `.gitignore` entries. +- Grep the repo and CI/docs for `DB2ToSqlite`, `dotnet`, `.csproj` references and update (README/build docs, any workflow that installs the .NET SDK). +- The 44-table `appsettings.json` subset is dead (both `make` targets pass the 72-table generator configs) — drop it; confirm no other caller (§10 Q). + +--- + +## 10. Open questions to resolve before / while building + +1. **Encrypted-section semantics — RESOLVED (verified against the `.db2`).** The tool uses no keys: all encrypted sections are zero-filled and skipped (§7 C1). v1 replicates the skip; no decryption. Optional future enhancement: enable Salsa20 + `WoW.txt` to pull in pre-release content — this *changes* output, so treat it as a separate feature (§3.2/§4). +2. **Hotfixes in the committed `db.json` — ANSWERED: YES, with hotfixes.** Phase D is therefore required for committed-artifact parity (§6, §7 H4); A/B gate against a without-hotfix reference (§8). +3. **Local-only vs CDN.** Is a complete local install guaranteed on every machine that runs `make db`? Note the stakes changed with revision note (4): the *current* tool is CDN-fed, so today `make db` works even against a partial install — the local-first port raises the bar to "every needed FDID resident locally". Confirm via the Phase B pre-flight (one-line dotnet BaseDir patch + byte-diff) that the local CASC path yields each table's data — in practice its single unencrypted section 0 (the encrypted sections are skipped, §7 C1) — so Phase C stays optional. PTR installs are likelier partial — check `wow_classic_ptr` specifically. `make db` is maintainer-run against a live install, re-run on each patch/hotfix (§1). +4. **Root/manifest variant — VERIFIED (against the live install's build config).** The `wow_classic` build config has a non-zero WoW `root = 8caf1829…` (a CKey — resolve via encoding to an EKey, then read from local CASC), so `OpenFileByFDID` uses the classic WoW root (MFST/TSFM). TVFS **is also present** (`vfs-root` + ~318 `vfs-N`) but is **not used** for FDID lookup — the config parser must *skip* `vfs-*` lines, not choke. (Caveat per revision note (4): the *current* tool resolves this same root via the CDN, not local CASC — the conclusion about which root type `OpenFileByFDID` uses is unaffected.) Remaining: the exact TSFM `dfVersion` (1 vs 2) still needs confirming by decoding the root file — either during the Phase B pre-flight (the BaseDir-patched dotnet run exercises it) or when the Go BLTE/`.idx` path lands. +5. **Reference capture — ANSWERED (partially): the maintainer regenerates it whenever new patches/hotfixes land; it is not a one-time frozen asset.** Formalize: keep a with-hotfix and a without-hotfix capture per build (§8 step 1), and decide where they live (local, not committed). +6. **`listfile.csv` strategy.** Static FDID map primary + Jenkins96/CSV fallback (recommended), and final on-disk location after `tools/DB2ToSqlite/` is deleted (recommended `tools/db2tool/listfile.csv`, repoint 3 literals). The full CSV is still required for the icon map regardless. +7. **`--output` vs settings `DatabaseFile` — ANSWERED: `DatabaseFile` is dead code.** Program.cs never reads the JSON key; the output path is the hardcoded default `wowsims.db` overridden only by `--output` / `-output` / `-o` (note the single-dash `-output` alias). The port should implement the flags and **not** read `DatabaseFile`; both `make` targets always pass `--output`. +8. **Exact-build match: keep bug-for-bug or add a reviewed range fallback?** Recommend exact-match + clear error now (schema-stability guarantee); treat any relaxation as a separate PR (§5.5, H1). Note the build changes every patch, so "build not yet in WoWDBDefs" is a routine, expected error, not an edge case. +9. **Slack tables.** Confirm none of the ~11 slack tables are read outside `tools/database` before treating their schema as non-critical (they may be staged for planned features). +10. **`SpellScaling.txt` double location.** Decide whether the committed `//go:embed` copy (`tools/database/dbc/GameTables/SpellScaling.txt`) should be auto-synced from the extracted `assets/db_inputs/basestats/SpellScaling.txt` or remain a manual, independently-committed file (status quo). +11. **Decrypt pre-release content? (optional, post-v1).** The tool has always run keyless (no `WoW.txt`), so encrypted sections are skipped and the sim omits unreleased items/spells. If that ever matters, decide whether to add the Salsa20 + `WoW.txt` decrypt path — noting it (a) changes output vs the golden, (b) needs a licensing read (TACTKeys has no license), and (c) needs a key-refresh story. Default: stay keyless. \ No newline at end of file diff --git a/tools/db2tool/NOTICES.md b/tools/db2tool/NOTICES.md new file mode 100644 index 0000000000..40f8ccdd3a --- /dev/null +++ b/tools/db2tool/NOTICES.md @@ -0,0 +1,74 @@ +# Third-party notices for `tools/db2tool` + +This tool is a pure-Go reimplementation of `tools/DB2ToSqlite` (.NET). Several +packages are Go translations (derivative works) of upstream C# libraries. Each +derived source file carries a short notice header pointing here; this file is +the authoritative list of upstreams, licenses, and pinned revisions. + +| Package dir | Upstream | License | Pinned revision | +|---|---|---|---| +| `wdc/` | [wowdev/DBCD](https://github.com/wowdev/DBCD) (DBCD + DBCD.IO, v2.1.2 — the version vendored as DLLs in `tools/DB2ToSqlite/references/`) | MIT, Copyright (c) 2020 wowdev | `2180edb4d08b3822b3cfa964293ba8ccd4236ac0` | +| `dbd/` | [wowdev/WoWDBDefs](https://github.com/wowdev/WoWDBDefs) `code/C#/DBDefsLib` (**code** is BSD-3-Clause; the `.dbd` **data** files are CC BY-SA 4.0 and are fetched at build time, never vendored) | BSD-3-Clause, Copyright 2022 WoWDBDefs Contributors | `9002c532853a96d631c76dda50cb20189c27a173` (master at port time; the vendored DBDefsLib.dll is v1.0.0 with no embedded commit) | +| `tact/` | [wowdev/TACTSharp](https://github.com/wowdev/TACTSharp) v0.0.13-alpha | MIT | `d0ab516eb98b5db35682467b6e4977d88955046d` | +| `sqlite/`, `config/`, `main.go` | original repo code (ports of this repo's own `tools/DB2ToSqlite/Helpers/*.cs` and `Program.cs`) | repo MIT | — | + +Runtime data dependencies (fetched, never vendored — see §4 of +`docs/db2tool-migration-plan.md`): + +- `.dbd` definitions from WoWDBDefs (`definitions/
.dbd`) — CC BY-SA 4.0, + cached under a gitignored `DBDCache/`. +- `listfile.csv` (community listfile) — cached, gitignored. +- No TACT keys are used; encrypted DB2 sections are skipped (plan §7 C1). + +## MIT License (wowdev/DBCD, wowdev/TACTSharp) + +MIT License + +Copyright (c) 2020 wowdev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## BSD-3-Clause (WoWDBDefs code — applies to `dbd/`; these files stay BSD-3-Clause, not relicensed) + +Copyright 2022 WoWDBDefs Contributors + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/tools/db2tool/config/config.go b/tools/db2tool/config/config.go new file mode 100644 index 0000000000..7a50ec9555 --- /dev/null +++ b/tools/db2tool/config/config.go @@ -0,0 +1,53 @@ +// Settings JSON binding for tools/db2tool — mirrors the configuration shape +// consumed by tools/DB2ToSqlite/Program.cs (generator-settings.json / +// ptr-generator-settings.json). Original repo code (MIT). +package config + +import ( + "encoding/json" + "fmt" + "os" +) + +// Settings mirrors the TACTSharp-bindable "Settings" section. Only the fields +// the tool actually consumes are used today; the rest are bound for +// compatibility (CacheDir is bound-but-unused in v1, plan §3.1). +type Settings struct { + Region string `json:"Region"` + Product string `json:"Product"` + BaseDir string `json:"BaseDir"` + BuildConfig string `json:"BuildConfig"` + CDNConfig string `json:"CDNConfig"` + CacheDir string `json:"CacheDir"` + Locale string `json:"Locale"` +} + +type File struct { + Settings Settings `json:"Settings"` + // TargetDirectory does double duty upstream (listfile-key prefix AND + // output dir — plan §7 M4); the FDID/listfile use must always see the raw + // value, never a filesystem-resolved path. + TargetDirectory string `json:"TargetDirectory"` + DatabaseFile string `json:"DatabaseFile"` // dead code upstream (plan §10 Q7); bound, never read + GameTablesOutDirectory string `json:"GameTablesOutDirectory"` + GameTables []string `json:"GameTables"` + Tables []string `json:"Tables"` +} + +func Load(path string) (*File, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var f File + if err := json.Unmarshal(raw, &f); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + if f.TargetDirectory == "" { + f.TargetDirectory = "dbfilesclient" + } + if f.GameTablesOutDirectory == "" { + f.GameTablesOutDirectory = "GameTables" + } + return &f, nil +} diff --git a/tools/db2tool/dbd/dbd.go b/tools/db2tool/dbd/dbd.go new file mode 100644 index 0000000000..7363d8e7ac --- /dev/null +++ b/tools/db2tool/dbd/dbd.go @@ -0,0 +1,540 @@ +// Go translation of DBDefsLib's DBDReader +// (https://github.com/wowdev/WoWDBDefs, code/C#/DBDefsLib). +// Copyright 2022 WoWDBDefs Contributors. Licensed under BSD-3-Clause; this +// file remains BSD-3-Clause (full text, including the non-endorsement clause, +// in tools/db2tool/NOTICES.md). Upstream commit 9002c532853a96d631c76dda50cb20189c27a173. +package dbd + +import ( + "fmt" + "io" + "os" + "strconv" + "strings" +) + +type ColumnDefinition struct { + Type string + ForeignTable string + ForeignColumn string + Verified bool + Comment string +} + +type Definition struct { + Size int + ArrLength int + Name string + IsID bool + IsRelation bool + IsNonInline bool + IsSigned bool + Comment string +} + +type Build struct { + Expansion int16 + Major int16 + Minor int16 + Build uint32 +} + +func ParseBuild(s string) (Build, error) { + split := strings.Split(s, ".") + if len(split) != 4 { + return Build{}, fmt.Errorf("invalid build string %q", s) + } + expansion, err := strconv.ParseInt(split[0], 10, 16) + if err != nil { + return Build{}, fmt.Errorf("invalid build string %q: %w", s, err) + } + major, err := strconv.ParseInt(split[1], 10, 16) + if err != nil { + return Build{}, fmt.Errorf("invalid build string %q: %w", s, err) + } + minor, err := strconv.ParseInt(split[2], 10, 16) + if err != nil { + return Build{}, fmt.Errorf("invalid build string %q: %w", s, err) + } + build, err := strconv.ParseUint(split[3], 10, 32) + if err != nil { + return Build{}, fmt.Errorf("invalid build string %q: %w", s, err) + } + return Build{Expansion: int16(expansion), Major: int16(major), Minor: int16(minor), Build: uint32(build)}, nil +} + +func (b Build) String() string { + return fmt.Sprintf("%d.%d.%d.%d", b.Expansion, b.Major, b.Minor, b.Build) +} + +func (b Build) Compare(o Build) int { + if b.Expansion != o.Expansion { + return int(b.Expansion) - int(o.Expansion) + } + if b.Major != o.Major { + return int(b.Major) - int(o.Major) + } + if b.Minor != o.Minor { + return int(b.Minor) - int(o.Minor) + } + if b.Build != o.Build { + if b.Build < o.Build { + return -1 + } + return 1 + } + return 0 +} + +type BuildRange struct { + MinBuild Build + MaxBuild Build +} + +func (r BuildRange) Contains(b Build) bool { + return b.Compare(r.MinBuild) >= 0 && b.Compare(r.MaxBuild) <= 0 +} + +func (r BuildRange) String() string { + return r.MinBuild.String() + "-" + r.MaxBuild.String() +} + +type VersionDefinitions struct { + Builds []Build + BuildRanges []BuildRange + LayoutHashes []string + Comment string + Definitions []Definition +} + +type DBDefinition struct { + ColumnDefinitions map[string]ColumnDefinition + VersionDefinitions []VersionDefinitions +} + +// ReadFile parses a .dbd definition file from disk. +func ReadFile(path string, validate bool) (DBDefinition, error) { + f, err := os.Open(path) + if err != nil { + return DBDefinition{}, err + } + defer f.Close() + def, err := Read(f, validate) + if err != nil { + return DBDefinition{}, fmt.Errorf("%s: %w", path, err) + } + return def, nil +} + +// Read parses a .dbd definition stream. It is a line-for-line transcription of +// DBDReader.Read (deliberately bug-for-bug where behavior is observable). +func Read(r io.Reader, validate bool) (DBDefinition, error) { + raw, err := io.ReadAll(r) + if err != nil { + return DBDefinition{}, err + } + lines := readLines(raw) + if len(lines) == 0 { + return DBDefinition{}, fmt.Errorf("empty .dbd file") + } + + columnDefinitions := make(map[string]ColumnDefinition) + lineNumber := 0 + + if !strings.HasPrefix(lines[0], "COLUMNS") { + return DBDefinition{}, fmt.Errorf("file does not start with column definitions") + } + + lineNumber++ + for lineNumber < len(lines) { + line := lines[lineNumber] + lineNumber++ + + // Column definitions are done after encountering a blank line. + if isBlank(line) { + break + } + + var colDef ColumnDefinition + + if !strings.Contains(line, " ") { + return DBDefinition{}, fmt.Errorf("line %q does not contain a space between type and column name", line) + } + + // Read line up to space (end of type) or < (foreign key). + typeEnd := strings.IndexAny(line, " <") + colType := line[:typeEnd] + switch colType { + case "uint", "int", "float", "string", "locstring": + colDef.Type = colType + default: + return DBDefinition{}, fmt.Errorf("invalid type %q on line %d", colType, lineNumber) + } + + // Only read foreign key if the identifier is right after the type. + if strings.HasPrefix(line, colType+"<") { + lt := strings.Index(line, "<") + gt := strings.Index(line, ">") + if gt < lt { + return DBDefinition{}, fmt.Errorf("malformed foreign key on line %d", lineNumber) + } + foreignKey := strings.Split(line[lt+1:gt], "::") + if len(foreignKey) != 2 { + return DBDefinition{}, fmt.Errorf("invalid foreign key length: %d", len(foreignKey)) + } + colDef.ForeignTable = foreignKey[0] + colDef.ForeignColumn = foreignKey[1] + } + + var name string + if strings.LastIndex(line, " ") == strings.Index(line, " ") { + // Simple line like "uint ID". + name = line[strings.Index(line, " ")+1:] + } else { + start := strings.Index(line, " ") + second := indexFrom(line, ' ', start+1) + name = line[start+1 : second] + } + + if strings.HasSuffix(name, "?") { + colDef.Verified = false + name = name[:len(name)-1] + } else { + colDef.Verified = true + } + + if idx := strings.Index(line, "//"); idx >= 0 { + colDef.Comment = strings.TrimSpace(line[idx+2:]) + } + + if _, exists := columnDefinitions[name]; exists { + fmt.Fprintf(os.Stderr, "dbd: collision with existing column name %q, skipping\n", name) + } else { + columnDefinitions[name] = colDef + } + } + + var versionDefinitions []VersionDefinitions + + var definitions []Definition + var layoutHashes []string + comment := "" + var builds []Build + var buildRanges []BuildRange + + flush := func() error { + if len(builds) != 0 || len(buildRanges) != 0 || len(layoutHashes) != 0 { + versionDefinitions = append(versionDefinitions, VersionDefinitions{ + Builds: append([]Build(nil), builds...), + BuildRanges: append([]BuildRange(nil), buildRanges...), + LayoutHashes: append([]string(nil), layoutHashes...), + Comment: comment, + Definitions: append([]Definition(nil), definitions...), + }) + } else if len(definitions) != 0 || !isBlank(comment) { + return fmt.Errorf("no BUILD or LAYOUT, but non-empty lines/definitions") + } + return nil + } + + for i := lineNumber; i < len(lines); i++ { + line := lines[i] + + if isBlank(line) { + if err := flush(); err != nil { + return DBDefinition{}, err + } + definitions = nil + layoutHashes = nil + comment = "" + builds = nil + buildRanges = nil + } + + if strings.HasPrefix(line, "LAYOUT") { + layoutHashes = append(layoutHashes, strings.Split(line[7:], ", ")...) + } + + if strings.HasPrefix(line, "BUILD") { + for _, splitBuild := range strings.Split(line[6:], ", ") { + if strings.Contains(splitBuild, "-") { + splitRange := strings.Split(splitBuild, "-") + minBuild, err := ParseBuild(splitRange[0]) + if err != nil { + return DBDefinition{}, err + } + maxBuild, err := ParseBuild(splitRange[1]) + if err != nil { + return DBDefinition{}, err + } + buildRanges = append(buildRanges, BuildRange{MinBuild: minBuild, MaxBuild: maxBuild}) + } else { + build, err := ParseBuild(splitBuild) + if err != nil { + return DBDefinition{}, err + } + builds = append(builds, build) + } + } + } + + if strings.HasPrefix(line, "COMMENT") { + comment = strings.TrimSpace(line[7:]) + } + + if !strings.HasPrefix(line, "LAYOUT") && !strings.HasPrefix(line, "BUILD") && + !strings.HasPrefix(line, "COMMENT") && !isBlank(line) { + definition := Definition{IsNonInline: false} + + if strings.Contains(line, "$") { + annotationStart := strings.Index(line, "$") + annotationEnd := indexFrom(line, '$', 1) + if annotationEnd < 0 { + return DBDefinition{}, fmt.Errorf("unterminated annotation on line %q", line) + } + annotations := strings.Split(line[annotationStart+1:annotationEnd], ",") + for _, a := range annotations { + switch a { + case "id": + definition.IsID = true + case "noninline": + definition.IsNonInline = true + case "relation": + definition.IsRelation = true + } + } + // C#: line = line.Remove(annotationStart, annotationEnd + 1) + line = line[:annotationStart] + line[annotationStart+annotationEnd+1:] + } + + if strings.Contains(line, "<") { + lt := strings.Index(line, "<") + gt := strings.Index(line, ">") + if gt < lt { + return DBDefinition{}, fmt.Errorf("malformed size on line %q", line) + } + size := line[lt+1 : gt] + if size == "" { + return DBDefinition{}, fmt.Errorf("empty size on line %q", line) + } + if size[0] == 'u' { + definition.IsSigned = false + n, err := strconv.Atoi(strings.ReplaceAll(size, "u", "")) + if err != nil { + return DBDefinition{}, fmt.Errorf("invalid size %q: %w", size, err) + } + definition.Size = n + } else { + definition.IsSigned = true + n, err := strconv.Atoi(size) + if err != nil { + return DBDefinition{}, fmt.Errorf("invalid size %q: %w", size, err) + } + definition.Size = n + } + line = line[:lt] + line[gt+1:] + } + + if strings.Contains(line, "[") { + lb := strings.Index(line, "[") + rb := strings.Index(line, "]") + if rb < lb { + return DBDefinition{}, fmt.Errorf("invalid array length format") + } + n, err := strconv.Atoi(line[lb+1 : rb]) + if err != nil { + return DBDefinition{}, fmt.Errorf("invalid array length format") + } + definition.ArrLength = n + line = line[:lb] + line[rb+1:] + } + + if idx := strings.Index(line, "//"); idx >= 0 { + definition.Comment = strings.TrimSpace(line[idx+2:]) + line = strings.TrimSpace(line[:idx]) + } + + definition.Name = line + + colDef, ok := columnDefinitions[definition.Name] + if !ok { + return DBDefinition{}, fmt.Errorf("unable to find %q in column definitions", definition.Name) + } + // Temporary unsigned format update conversion code (upstream). + if colDef.Type == "uint" { + definition.IsSigned = false + } + + definitions = append(definitions, definition) + } + + if len(lines) == i+1 { + if err := flush(); err != nil { + return DBDefinition{}, err + } + } + } + + if validate { + if err := runValidation(columnDefinitions, versionDefinitions); err != nil { + return DBDefinition{}, err + } + } + + return DBDefinition{ + ColumnDefinitions: columnDefinitions, + VersionDefinitions: versionDefinitions, + }, nil +} + +// runValidation ports the optional validate block of DBDReader.Read. Console +// warnings become stderr prints; exceptions become errors. It also removes +// column definitions never used by any version block, as upstream does. +func runValidation(columnDefinitions map[string]ColumnDefinition, versionDefinitions []VersionDefinitions) error { + for name := range columnDefinitions { + found := false + for _, version := range versionDefinitions { + for _, definition := range version.Definitions { + if name == definition.Name { + found = true + break + } + } + if found { + break + } + } + if !found { + fmt.Fprintf(os.Stderr, "dbd: column definition %q is never used in version definitions\n", name) + delete(columnDefinitions, name) + } + } + + seenBuilds := make(map[Build]bool) + seenLayoutHashes := make(map[string]bool) + + for _, version := range versionDefinitions { + for _, build := range version.Builds { + if seenBuilds[build] { + return fmt.Errorf("build %s is already defined", build) + } + seenBuilds[build] = true + } + + for _, layoutHash := range version.LayoutHashes { + if seenLayoutHashes[layoutHash] { + return fmt.Errorf("layout hash %s is already defined", layoutHash) + } + seenLayoutHashes[layoutHash] = true + if len(layoutHash) != 8 { + return fmt.Errorf("layout hash %q is wrong length", layoutHash) + } + } + + for _, definition := range version.Definitions { + colType := columnDefinitions[definition.Name].Type + if (colType == "int" || colType == "uint") && definition.Size == 0 { + return fmt.Errorf("version definition %s is an int/uint but is missing size", definition.Name) + } + if colType != "int" && colType != "uint" && definition.Size != 0 { + return fmt.Errorf("version definition %s is NOT an int/uint but has size", definition.Name) + } + } + + names := make(map[string]bool) + for _, definition := range version.Definitions { + if names[definition.Name] { + return fmt.Errorf("version definitions contains multiple columns of the same name") + } + names[definition.Name] = true + } + } + + for i := range versionDefinitions { + for j := range versionDefinitions { + if i == j { + continue + } + for _, r := range versionDefinitions[i].BuildRanges { + for _, b := range versionDefinitions[j].Builds { + if r.Contains(b) { + return fmt.Errorf("build %s conflicts with %s", b, r) + } + } + for _, or := range versionDefinitions[j].BuildRanges { + if r.Contains(or.MinBuild) || r.Contains(or.MaxBuild) { + return fmt.Errorf("build %s conflicts with %s", or, r) + } + } + } + + if definitionsEqual(versionDefinitions[i].Definitions, versionDefinitions[j].Definitions) { + if len(versionDefinitions[i].LayoutHashes) > 0 && len(versionDefinitions[j].LayoutHashes) > 0 && + !stringSlicesEqual(versionDefinitions[i].LayoutHashes, versionDefinitions[j].LayoutHashes) { + // Upstream ignores this case (identical definitions, different layout hashes). + } else { + return fmt.Errorf("dbd file has 2 identical version definitions (%d and %d)", i+1, j+1) + } + } + } + } + + return nil +} + +func definitionsEqual(a, b []Definition) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func stringSlicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// readLines splits raw file bytes exactly like C# StreamReader.ReadLine: +// \r\n, \r, and \n all terminate a line, a terminator at EOF does not produce +// a trailing empty line, and a leading UTF-8 BOM is stripped. +func readLines(raw []byte) []string { + s := string(raw) + s = strings.TrimPrefix(s, "\ufeff") + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + lines := strings.Split(s, "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" && strings.HasSuffix(s, "\n") { + lines = lines[:len(lines)-1] + } + if len(lines) == 1 && lines[0] == "" { + return nil + } + return lines +} + +func isBlank(s string) bool { + return strings.TrimSpace(s) == "" +} + +func indexFrom(s string, c byte, from int) int { + if from >= len(s) { + return -1 + } + idx := strings.IndexByte(s[from:], c) + if idx < 0 { + return -1 + } + return from + idx +} diff --git a/tools/db2tool/dbd/dbd_test.go b/tools/db2tool/dbd/dbd_test.go new file mode 100644 index 0000000000..d324584347 --- /dev/null +++ b/tools/db2tool/dbd/dbd_test.go @@ -0,0 +1,128 @@ +package dbd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Fixture values are frozen from the build-68571 snapshot in +// tools/DB2ToSqlite/DBDCache (plan §8 step 2). The tests skip when the +// gitignored snapshot is absent (e.g. CI). +const snapshotBuild = 68571 + +const dbdCacheDir = "../../DB2ToSqlite/DBDCache" + +func snapshotFiles(t *testing.T) []string { + t.Helper() + entries, err := os.ReadDir(dbdCacheDir) + if os.IsNotExist(err) { + t.Skipf("%s not present (gitignored snapshot); skipping", dbdCacheDir) + } + if err != nil { + t.Fatal(err) + } + var files []string + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".dbd") { + files = append(files, filepath.Join(dbdCacheDir, e.Name())) + } + } + if len(files) != 72 { + t.Fatalf("expected 72 .dbd files in snapshot, got %d", len(files)) + } + return files +} + +func TestParseSnapshotAndSelect68571(t *testing.T) { + typeCounts := map[string]int{} + for _, file := range snapshotFiles(t) { + def, err := ReadFile(file, true) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + version, err := SelectVersion(def, snapshotBuild) + if err != nil { + t.Fatalf("select %d in %s: %v", snapshotBuild, file, err) + } + if len(version.Definitions) == 0 { + t.Fatalf("%s: selected version has no definitions", file) + } + for _, d := range version.Definitions { + col, ok := def.ColumnDefinitions[d.Name] + if !ok { + t.Fatalf("%s: definition %q missing from COLUMNS", file, d.Name) + } + typeCounts[col.Type]++ + } + } + + // Frozen per-build totals for the selected 68571 blocks (plan §8 step 2). + want := map[string]int{"int": 515, "float": 65, "locstring": 43, "string": 5} + for typ, n := range want { + if typeCounts[typ] != n { + t.Errorf("type %s: got %d definitions, want %d", typ, typeCounts[typ], n) + } + } + if typeCounts["uint"] != 0 { + t.Errorf("expected no uint columns in selected blocks, got %d", typeCounts["uint"]) + } +} + +func TestItemRandomSuffixShape(t *testing.T) { + if _, err := os.Stat(dbdCacheDir); os.IsNotExist(err) { + t.Skipf("%s not present; skipping", dbdCacheDir) + } + def, err := ReadFile(filepath.Join(dbdCacheDir, "ItemRandomSuffix.dbd"), true) + if err != nil { + t.Fatal(err) + } + version, err := SelectVersion(def, snapshotBuild) + if err != nil { + t.Fatal(err) + } + + byName := map[string]Definition{} + for _, d := range version.Definitions { + byName[d.Name] = d + } + + // §5.5: the 68571 block must be the <32>[5] one, not the legacy [3]. + if got := byName["AllocationPct"].ArrLength; got != 5 { + t.Errorf("AllocationPct arrLength = %d, want 5", got) + } + if got := byName["Enchantment"].ArrLength; got != 5 { + t.Errorf("Enchantment arrLength = %d, want 5", got) + } + id := byName["ID"] + if !id.IsID || !id.IsNonInline || id.Size != 32 { + t.Errorf("ID definition = %+v, want isID + noninline + size 32", id) + } +} + +func TestItemSparseBuildSuffixedField(t *testing.T) { + if _, err := os.Stat(dbdCacheDir); os.IsNotExist(err) { + t.Skipf("%s not present; skipping", dbdCacheDir) + } + def, err := ReadFile(filepath.Join(dbdCacheDir, "ItemSparse.dbd"), true) + if err != nil { + t.Fatal(err) + } + version, err := SelectVersion(def, snapshotBuild) + if err != nil { + t.Fatal(err) + } + found := false + for _, d := range version.Definitions { + if d.Name == "Field_1_15_3_55112_014" { + found = true + if d.ArrLength != 10 { + t.Errorf("Field_1_15_3_55112_014 arrLength = %d, want 10", d.ArrLength) + } + } + } + if !found { + t.Error("Field_1_15_3_55112_014 not present in selected ItemSparse block") + } +} diff --git a/tools/db2tool/dbd/select.go b/tools/db2tool/dbd/select.go new file mode 100644 index 0000000000..03fad209bf --- /dev/null +++ b/tools/db2tool/dbd/select.go @@ -0,0 +1,29 @@ +// Go translation of the version-selection rule used by this repo's +// SQLiteDbCreator.cs / SqliteDataInserter.cs (see docs/db2tool-migration-plan.md §5.1). +// Derived from DBDefsLib types (https://github.com/wowdev/WoWDBDefs). +// Copyright 2022 WoWDBDefs Contributors. BSD-3-Clause — see tools/db2tool/NOTICES.md. +package dbd + +import "fmt" + +// SelectVersion returns the LAST versionDefinition (in file order) whose +// Builds list contains an entry with trailing build number == buildNumber. +// +// This is deliberately the SQLite helpers' rule replicated bug-for-bug: +// exact equality on the trailing build number only — buildRanges and +// layoutHashes are NOT consulted (plan §5.1). The C# row-decode half uses a +// different rule (first match on the full 4-part version, with range and +// layout-hash fallbacks); at the time of the port both rules resolve to the +// same block for every configured table, and this single selector fails loud +// exactly where the two C# halves would diverge. +func SelectVersion(def DBDefinition, buildNumber uint32) (VersionDefinitions, error) { + for i := len(def.VersionDefinitions) - 1; i >= 0; i-- { + for _, b := range def.VersionDefinitions[i].Builds { + if b.Build == buildNumber { + return def.VersionDefinitions[i], nil + } + } + } + return VersionDefinitions{}, fmt.Errorf( + "build %d not found in the .dbd definition — WoWDBDefs may not contain this build yet; wait for upstream or refresh DBDCache", buildNumber) +} diff --git a/tools/db2tool/golden_test.go b/tools/db2tool/golden_test.go new file mode 100644 index 0000000000..7909dc656a --- /dev/null +++ b/tools/db2tool/golden_test.go @@ -0,0 +1,189 @@ +package main + +import ( + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/wowsims/mop/tools/db2tool/config" + "github.com/wowsims/mop/tools/db2tool/dbd" + "github.com/wowsims/mop/tools/db2tool/internal/golden" + "github.com/wowsims/mop/tools/db2tool/sqlite" + "github.com/wowsims/mop/tools/db2tool/wdc" + _ "modernc.org/sqlite" +) + +// Golden gate (plan §8): builds a wowsims.db from the pre-extracted snapshot +// and diffs it against a reference produced by the .NET tool. +// +// - Schema parity is ALWAYS strict — hotfixes never change schema. +// - Row parity is strict when DB2TOOL_REF_DB points at a without-hotfix +// reference capture (plan §8 step 1). Against the default repo reference +// (tools/database/wowsims.db, built WITH hotfixes), small per-table diffs +// are tolerated and logged: they are the hotfix overlay Phase D will +// apply. A systematic decoder bug produces thousands of diff lines and +// still fails. +// +// Skips when the gitignored snapshot/reference are absent (e.g. CI). +func TestGoldenParity(t *testing.T) { + const snapshotBuild = 68571 + db2Dir := "../DB2ToSqlite/dbfilesclient" + dbdDir := "../DB2ToSqlite/DBDCache" + settingsPath := "../database/generator-settings.json" + + refPath := os.Getenv("DB2TOOL_REF_DB") + strict := refPath != "" + if refPath == "" { + refPath = "../database/wowsims.db" + } + for _, p := range []string{db2Dir, dbdDir, settingsPath, refPath} { + if _, err := os.Stat(p); os.IsNotExist(err) { + t.Skipf("%s not present; skipping golden gate", p) + } + } + + settings, err := config.Load(settingsPath) + if err != nil { + t.Fatal(err) + } + + outPath := filepath.Join(t.TempDir(), "wowsims.go.db") + goDB, err := sqlite.Open(outPath) + if err != nil { + t.Fatal(err) + } + defer goDB.Close() + + var tableDefs []sqlite.TableDef + decodedByTable := map[string]*wdc.Decoded{} + floatCols := map[string][]int{} // table -> float array definition indexes + + for _, tableName := range settings.Tables { + table, err := wdc.ReadFile(filepath.Join(db2Dir, tableName+".db2")) + if err != nil { + t.Fatal(err) + } + def, err := dbd.ReadFile(filepath.Join(dbdDir, tableName+".dbd"), true) + if err != nil { + t.Fatal(err) + } + version, err := dbd.SelectVersion(def, snapshotBuild) + if err != nil { + t.Fatalf("%s: %v", tableName, err) + } + decoded, err := table.DecodeRows(def, version, snapshotBuild) + if err != nil { + t.Fatalf("%s: %v", tableName, err) + } + tableDefs = append(tableDefs, sqlite.TableDef{Name: tableName, Def: def, Version: version}) + decodedByTable[tableName] = decoded + for i, d := range version.Definitions { + if def.ColumnDefinitions[d.Name].Type == "float" { + floatCols[tableName] = append(floatCols[tableName], i) + } + } + } + + if err := sqlite.CreateTables(goDB, tableDefs); err != nil { + t.Fatal(err) + } + for _, td := range tableDefs { + if err := sqlite.InsertRows(goDB, td, decodedByTable[td.Name]); err != nil { + t.Fatal(err) + } + } + + refDB, err := sql.Open("sqlite", refPath) + if err != nil { + t.Fatal(err) + } + defer refDB.Close() + + // 1. Schema parity — strict. + refSchema, err := golden.SchemaDDL(refDB) + if err != nil { + t.Fatal(err) + } + goSchema, err := golden.SchemaDDL(goDB) + if err != nil { + t.Fatal(err) + } + if len(refSchema) != len(goSchema) { + t.Fatalf("schema object count: ref %d vs go %d", len(refSchema), len(goSchema)) + } + for i := range refSchema { + if refSchema[i] != goSchema[i] { + t.Errorf("schema mismatch:\n ref: %s\n go: %s", refSchema[i], goSchema[i]) + } + } + + // 2. §5.5 float-notation risk: no critical-table float ARRAY element may + // fall in the C#-vs-Go divergent text ranges. Scalars are exempt: they + // bind numerically as REAL and never go through text formatting (e.g. + // SpellEffect has ±1e17 scalar coefficients that are byte-identical in + // the reference). + critical := map[string]bool{} + for _, name := range golden.CriticalTables { + critical[name] = true + } + for tableName, cols := range floatCols { + if !critical[tableName] { + continue + } + for _, decodedRow := range decodedByTable[tableName].Rows { + for _, ci := range cols { + switch v := decodedRow.Values[ci].(type) { + case []float32: + for _, f := range v { + if golden.FloatDiverges(f) { + t.Errorf("%s row %d: float %v in divergent notation range (implement C#-compatible formatter, §5.5)", tableName, decodedRow.ID, f) + } + } + } + } + } + } + + // 3. Row parity. + const hotfixTolerance = 12 // diff lines per table vs a with-hotfix reference + totalDiff := 0 + for _, td := range tableDefs { + refRows, err := golden.DumpRows(refDB, td.Name) + if err != nil { + t.Fatalf("ref %s: %v", td.Name, err) + } + goRows, err := golden.DumpRows(goDB, td.Name) + if err != nil { + t.Fatalf("go %s: %v", td.Name, err) + } + refOnly, goOnly := golden.DiffLines(refRows, goRows) + n := len(refOnly) + len(goOnly) + totalDiff += n + if n == 0 { + continue + } + if !critical[td.Name] { + t.Logf("%s (slack): %d diff lines (informational)", td.Name, n) + continue + } + if strict || n > hotfixTolerance { + for i, l := range refOnly { + if i >= 3 { + break + } + t.Errorf("%s: ref-only row: %.200s", td.Name, l) + } + for i, l := range goOnly { + if i >= 3 { + break + } + t.Errorf("%s: go-only row: %.200s", td.Name, l) + } + t.Errorf("%s: %d row diff lines", td.Name, n) + } else { + t.Logf("%s: %d diff lines (within with-hotfix tolerance — expected Phase D deltas)", td.Name, n) + } + } + t.Logf("total row diff lines across all tables: %d", totalDiff) +} diff --git a/tools/db2tool/internal/golden/golden.go b/tools/db2tool/internal/golden/golden.go new file mode 100644 index 0000000000..0b4bb7aadb --- /dev/null +++ b/tools/db2tool/internal/golden/golden.go @@ -0,0 +1,166 @@ +// Package golden is the validation harness for tools/db2tool (plan §8): it +// compares a Go-built wowsims.db against a reference produced by the .NET +// tool — schema DDL, per-table row counts, and canonical row dumps. +package golden + +import ( + "database/sql" + "fmt" + "math" + "strings" +) + +// CriticalTables is the §5.6 byte-exact-critical set: row + value parity +// required. Slack tables must merely extract without error. +var CriticalTables = []string{ + "Item", "ItemSparse", "SpellEffect", "SpellItemEnchantment", "ItemRandomSuffix", + "RandPropPoints", "SpellMisc", + "ItemDamageAmmo", "ItemDamageOneHand", "ItemDamageOneHandCaster", "ItemDamageRanged", + "ItemDamageThrown", "ItemDamageTwoHand", "ItemDamageTwoHandCaster", "ItemDamageWand", + "ItemArmorQuality", "ItemArmorShield", "ItemArmorTotal", "ArmorLocation", + "GemProperties", "ItemEffect", "ItemClass", "ItemSubClass", "ItemSet", + "ItemNameDescription", "RulesetItemUpgrade", "ItemUpgrade", + "Spell", "SpellName", "SpellLevels", "SpellCooldowns", "SpellScaling", "SpellLabel", + "SpellCategories", "SpellCategory", "SpellDuration", "SpellPower", "SpellInterrupts", + "SpellEquippedItems", "SpellAuraOptions", "SpellClassOptions", "SpellShapeshift", + "SpellXDescriptionVariables", "SpellDescriptionVariables", "SpellTargetRestrictions", + "SpellRange", "SpellRadius", "SpellProcsPerMinute", "SpellProcsPerMinuteMod", + "GlyphProperties", "SkillLineAbility", "Talent", "Faction", "Map", + "JournalEncounter", "JournalEncounterItem", "JournalInstance", "AreaTable", +} + +// SchemaDDL returns the whitespace-normalized sqlite_master entries, sorted, +// excluding objects related to item_enchantment_template (created later by +// gen_db's overrides, not by the extractor). +func SchemaDDL(db *sql.DB) ([]string, error) { + rows, err := db.Query(`SELECT type, name, tbl_name, COALESCE(sql,'') FROM sqlite_master + WHERE name != 'item_enchantment_template' AND tbl_name != 'item_enchantment_template' + ORDER BY type, name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var typ, name, tbl, ddl string + if err := rows.Scan(&typ, &name, &tbl, &ddl); err != nil { + return nil, err + } + out = append(out, fmt.Sprintf("%s|%s|%s|%s", typ, name, tbl, strings.Join(strings.Fields(ddl), " "))) + } + return out, rows.Err() +} + +// TableNames lists extractor-created tables in sqlite_master order. +func TableNames(db *sql.DB) ([]string, error) { + rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' + AND name != 'item_enchantment_template' ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + out = append(out, name) + } + return out, rows.Err() +} + +func pkColumn(db *sql.DB, table string) (string, error) { + var pk string + err := db.QueryRow(fmt.Sprintf("SELECT name FROM pragma_table_info('%s') WHERE pk=1", table)).Scan(&pk) + return pk, err +} + +// DumpRows returns one canonical line per row (SELECT * ORDER BY pk), using +// the driver's text rendering so both databases go through identical +// formatting. +func DumpRows(db *sql.DB, table string) ([]string, error) { + pk, err := pkColumn(db, table) + if err != nil { + return nil, fmt.Errorf("%s: no pk: %w", table, err) + } + rows, err := db.Query(fmt.Sprintf("SELECT * FROM [%s] ORDER BY [%s]", table, pk)) + if err != nil { + return nil, err + } + defer rows.Close() + cols, err := rows.Columns() + if err != nil { + return nil, err + } + var out []string + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + var sb strings.Builder + for rows.Next() { + if err := rows.Scan(ptrs...); err != nil { + return nil, err + } + sb.Reset() + for i, v := range vals { + if i > 0 { + sb.WriteByte('|') + } + switch t := v.(type) { + case nil: + sb.WriteString("") + case []byte: + sb.Write(t) + case string: + sb.WriteString(t) + case int64: + fmt.Fprintf(&sb, "%d", t) + case float64: + // %v matches across both DBs; exactness comes from comparing + // the same driver rendering on both sides. + fmt.Fprintf(&sb, "%v", t) + default: + fmt.Fprintf(&sb, "%v", t) + } + } + out = append(out, sb.String()) + } + return out, rows.Err() +} + +// DiffLines reports lines present on only one side (unified count, not +// positions) — enough to gate parity and cheap on 100k-row tables. +func DiffLines(ref, got []string) (refOnly, gotOnly []string) { + counts := make(map[string]int, len(ref)) + for _, l := range ref { + counts[l]++ + } + for _, l := range got { + if counts[l] > 0 { + counts[l]-- + } else { + gotOnly = append(gotOnly, l) + } + } + for l, n := range counts { + for i := 0; i < n; i++ { + refOnly = append(refOnly, l) + } + } + return refOnly, gotOnly +} + +// FloatDivergenceRanges reports whether a float32 value falls where C#'s and +// Go's shortest-round-trip text renderings diverge (plan §5.5): C# switches +// to scientific notation for |v| < 1e-4 or >= 1e15, Go only below 1e-6 or at +// >= 1e21. Zero is fine. +func FloatDiverges(v float32) bool { + a := math.Abs(float64(v)) + if a == 0 { + return false + } + return (a >= 1e-6 && a < 1e-4) || (a >= 1e15 && a < 1e21) +} diff --git a/tools/db2tool/main.go b/tools/db2tool/main.go new file mode 100644 index 0000000000..4ff9c8fadd --- /dev/null +++ b/tools/db2tool/main.go @@ -0,0 +1,150 @@ +// db2tool extracts World of Warcraft client data into tools/database/wowsims.db, +// replacing the .NET tools/DB2ToSqlite tool (see docs/db2tool-migration-plan.md). +// +// Phase A form: decodes pre-extracted .db2 files (default: +// tools/DB2ToSqlite/dbfilesclient) against cached .dbd definitions (default: +// tools/DB2ToSqlite/DBDCache) for an explicit --build number. Local CASC +// extraction (Phase B) will replace the pre-extracted inputs and derive the +// build from the install's .build.info. +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/wowsims/mop/tools/db2tool/config" + "github.com/wowsims/mop/tools/db2tool/dbd" + "github.com/wowsims/mop/tools/db2tool/sqlite" + "github.com/wowsims/mop/tools/db2tool/wdc" + _ "modernc.org/sqlite" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "db2tool:", err) + os.Exit(1) + } +} + +type options struct { + settingsFile string + databaseFile string + db2Dir string + dbdDir string + buildNumber uint32 +} + +// parseArgs mirrors Program.cs's pairwise scan, including the flag aliases +// (--settings/-s, --output/-output/-o) plus the Phase A-only flags. +func parseArgs(args []string) (options, error) { + opts := options{ + settingsFile: "appsettings.json", + databaseFile: "wowsims.db", + db2Dir: "tools/DB2ToSqlite/dbfilesclient", + dbdDir: "tools/DB2ToSqlite/DBDCache", + } + for i := 0; i < len(args); i++ { + next := func() (string, error) { + if i+1 < len(args) { + i++ + return args[i], nil + } + return "", fmt.Errorf("flag %s needs a value", args[i]) + } + var err error + switch args[i] { + case "--settings", "-s": + opts.settingsFile, err = next() + case "--output", "-output", "-o": + opts.databaseFile, err = next() + case "--db2dir": + opts.db2Dir, err = next() + case "--dbddir": + opts.dbdDir, err = next() + case "--build": + var v string + if v, err = next(); err == nil { + b, perr := dbd.ParseBuild("0.0.0." + v) + if perr != nil { + return opts, fmt.Errorf("invalid --build %q: %w", v, perr) + } + opts.buildNumber = b.Build + } + default: + return opts, fmt.Errorf("unknown argument %q", args[i]) + } + if err != nil { + return opts, err + } + } + return opts, nil +} + +func run(args []string) error { + opts, err := parseArgs(args) + if err != nil { + return err + } + if opts.buildNumber == 0 { + return fmt.Errorf("--build is required in the Phase A driver (later derived from .build.info)") + } + + settings, err := config.Load(opts.settingsFile) + if err != nil { + return fmt.Errorf("loading settings: %w", err) + } + if len(settings.Tables) == 0 { + return fmt.Errorf("settings file lists no Tables") + } + + type loaded struct { + def sqlite.TableDef + decoded *wdc.Decoded + } + tables := make([]loaded, 0, len(settings.Tables)) + tableDefs := make([]sqlite.TableDef, 0, len(settings.Tables)) + + for _, tableName := range settings.Tables { + table, err := wdc.ReadFile(filepath.Join(opts.db2Dir, tableName+".db2")) + if err != nil { + return err + } + def, err := dbd.ReadFile(filepath.Join(opts.dbdDir, tableName+".dbd"), true) + if err != nil { + return err + } + version, err := dbd.SelectVersion(def, opts.buildNumber) + if err != nil { + return fmt.Errorf("table %s: %w", tableName, err) + } + decoded, err := table.DecodeRows(def, version, opts.buildNumber) + if err != nil { + return fmt.Errorf("table %s: %w", tableName, err) + } + td := sqlite.TableDef{Name: tableName, Def: def, Version: version} + tables = append(tables, loaded{def: td, decoded: decoded}) + tableDefs = append(tableDefs, td) + } + + db, err := sqlite.Open(opts.databaseFile) + if err != nil { + return err + } + defer db.Close() + + if err := sqlite.CreateTables(db, tableDefs); err != nil { + return err + } + + // Hotfixes (Phase D) would be applied here, before the inserts. + + for _, t := range tables { + if err := sqlite.InsertRows(db, t.def, t.decoded); err != nil { + return err + } + } + + fmt.Println("Processing completed.") + return nil +} diff --git a/tools/db2tool/sqlite/insert.go b/tools/db2tool/sqlite/insert.go new file mode 100644 index 0000000000..ff662332ef --- /dev/null +++ b/tools/db2tool/sqlite/insert.go @@ -0,0 +1,135 @@ +// Port of this repo's tools/DB2ToSqlite/Helpers/SqliteDataInserter.cs. +// Original repo code (MIT), no external attribution owed. +package sqlite + +import ( + "database/sql" + "encoding/json" + "fmt" + "math" + "strings" + + "github.com/wowsims/mop/tools/db2tool/wdc" +) + +// InsertRows upserts every decoded row of one table inside one transaction. +// +// Contract notes (plan §5.4/§5.5, all verified against the reference DB): +// - definition order = column order = bind order; +// - relation-column idx_ indexes use table-less names (idx_) +// with IF NOT EXISTS, so only the first table processed with a given +// relation-column name gets the index — tables MUST be processed in +// settings order; +// - relation values of 0 stay 0 (the C# 0→NULL branch is a boxed reference +// comparison that is always false — dead code); +// - arrays serialize as JSON text via encoding/json over plain numeric +// slices, matching C# System.Text.Json's Array-declared serialization +// (boxed elements — u8 arrays emit [0,0,0], never base64); +// - float scalars bind as the double-widened float32. +func InsertRows(db *sql.DB, t TableDef, decoded *wdc.Decoded) error { + defs := t.Version.Definitions + + columnNames := make([]string, len(defs)) + for i, d := range defs { + columnNames[i] = d.Name + } + + pkColumn := "" + for _, d := range defs { + if d.IsID { + pkColumn = d.Name + break + } + } + if pkColumn == "" { + return fmt.Errorf("table %s has no id column", t.Name) + } + + var cols, vals []string + for _, c := range columnNames { + cols = append(cols, "["+c+"]") + vals = append(vals, "@"+c) + } + + var updates []string + for _, c := range columnNames { + if c != pkColumn { + updates = append(updates, fmt.Sprintf("[%s] = excluded.[%s]", c, c)) + } + } + updateClause := "DO NOTHING" + if len(updates) > 0 { + updateClause = "DO UPDATE SET " + strings.Join(updates, ", ") + } + + upsertSql := fmt.Sprintf("INSERT INTO [%s] (%s) VALUES (%s) ON CONFLICT([%s]) %s;", + t.Name, strings.Join(cols, ", "), strings.Join(vals, ", "), pkColumn, updateClause) + + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // Relation-column indexes, created before the inserts like the C# tool. + for _, d := range defs { + if d.IsRelation { + stmt := fmt.Sprintf("CREATE INDEX IF NOT EXISTS idx_%s ON %s (%s);", strings.ToLower(d.Name), t.Name, d.Name) + if _, err := tx.Exec(stmt); err != nil { + return fmt.Errorf("creating relation index on %s.%s: %w", t.Name, d.Name, err) + } + } + } + + stmt, err := tx.Prepare(upsertSql) + if err != nil { + return fmt.Errorf("preparing upsert for %s: %w", t.Name, err) + } + defer stmt.Close() + + args := make([]any, len(defs)) + for _, row := range decoded.Rows { + if len(row.Values) != len(defs) { + return fmt.Errorf("table %s row %d: %d values for %d definitions", t.Name, row.ID, len(row.Values), len(defs)) + } + for i, value := range row.Values { + bound, err := bindValue(value) + if err != nil { + return fmt.Errorf("table %s row %d column %s: %w", t.Name, row.ID, defs[i].Name, err) + } + args[i] = sql.Named(defs[i].Name, bound) + } + if _, err := stmt.Exec(args...); err != nil { + return fmt.Errorf("inserting row %d into %s: %w", row.ID, t.Name, err) + } + } + + return tx.Commit() +} + +// bindValue converts a decoded value into a driver-bindable one. +func bindValue(value any) (any, error) { + switch v := value.(type) { + case nil: + return nil, nil + case int64: + return v, nil + case uint64: + if v > math.MaxInt64 { + return nil, fmt.Errorf("uint64 value %d overflows INTEGER", v) + } + return int64(v), nil + case float32: + return float64(v), nil // double-widened, same as Microsoft.Data.Sqlite + case string: + return v, nil + case []int64, []uint64, []float32, []string: + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + return string(b), nil + default: + return nil, fmt.Errorf("unsupported value type %T", value) + } +} diff --git a/tools/db2tool/sqlite/schema.go b/tools/db2tool/sqlite/schema.go new file mode 100644 index 0000000000..e3d1c70ef7 --- /dev/null +++ b/tools/db2tool/sqlite/schema.go @@ -0,0 +1,125 @@ +// Port of this repo's tools/DB2ToSqlite/Helpers/SQLiteDbCreator.cs. +// Original repo code (MIT), no external attribution owed. +package sqlite + +import ( + "database/sql" + "fmt" + "os" + "strings" + + "github.com/wowsims/mop/tools/db2tool/dbd" +) + +// TableDef pairs a table name with its parsed definition and the version +// block selected for the current build (plan §5.1 selection rule; the caller +// selects once and both schema and inserts use the same block). +type TableDef struct { + Name string + Def dbd.DBDefinition + Version dbd.VersionDefinitions +} + +// Open deletes any pre-existing database file (SQLiteDbCreator.cs:11 — every +// run starts from an empty file; this is what makes post-patch re-runs and +// db/ptrdb alternation correct, plan §5) and opens a fresh connection with +// PRAGMA foreign_keys = ON. +func Open(path string) (*sql.DB, error) { + if _, err := os.Stat(path); err == nil { + if err := os.Remove(path); err != nil { + return nil, fmt.Errorf("deleting existing database: %w", err) + } + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + // The writer is single-threaded; a single connection keeps transaction + // semantics identical to the C# tool's one SqliteConnection. + db.SetMaxOpenConns(1) + if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil { + db.Close() + return nil, err + } + return db, nil +} + +// CreateTables emits the schema for every table, in order, inside one +// transaction — a transcription of SQLiteDbCreator.CreateDatabaseWithDefinitions. +func CreateTables(db *sql.DB, tables []TableDef) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + for _, t := range tables { + var columnDefinitionsSql []string + var indexSql []string + + for _, def := range t.Version.Definitions { + colDef, ok := t.Def.ColumnDefinitions[def.Name] + if !ok { + return fmt.Errorf("column definition for %s not found in table %s", def.Name, t.Name) + } + + if def.ArrLength == 0 { + sqliteType, err := mapToSQLiteType(colDef.Type) + if err != nil { + return fmt.Errorf("table %s column %s: %w", t.Name, def.Name, err) + } + nullability := "" + if colDef.ForeignTable != "" && colDef.ForeignColumn != "" && !def.IsID { + nullability = " NULL" + } + columnSql := fmt.Sprintf("[%s] %s%s", def.Name, sqliteType, nullability) + if def.IsID { + columnSql += " PRIMARY KEY" + } + columnDefinitionsSql = append(columnDefinitionsSql, columnSql) + + if colDef.ForeignTable != "" && colDef.ForeignColumn != "" { + indexSql = append(indexSql, fmt.Sprintf( + "CREATE INDEX IF NOT EXISTS IX_%s_%s ON [%s] ([%s])", t.Name, def.Name, t.Name, def.Name)) + } + } else { + columnDefinitionsSql = append(columnDefinitionsSql, fmt.Sprintf("[%s] TEXT", def.Name)) + + elementType, err := mapToSQLiteType(colDef.Type) + if err != nil { + return fmt.Errorf("table %s column %s: %w", t.Name, def.Name, err) + } + for i := 0; i < def.ArrLength; i++ { + columnDefinitionsSql = append(columnDefinitionsSql, fmt.Sprintf( + "[%s_%d] %s GENERATED ALWAYS AS (json_extract([%s], '$[%d]')) VIRTUAL", + def.Name, i, elementType, def.Name, i)) + } + } + } + + createTableSql := fmt.Sprintf("CREATE TABLE IF NOT EXISTS [%s] (%s);", t.Name, strings.Join(columnDefinitionsSql, ", ")) + if _, err := tx.Exec(createTableSql); err != nil { + return fmt.Errorf("creating table %s: %w", t.Name, err) + } + for _, stmt := range indexSql { + if _, err := tx.Exec(stmt); err != nil { + return fmt.Errorf("creating index on %s: %w", t.Name, err) + } + } + } + + return tx.Commit() +} + +func mapToSQLiteType(colType string) (string, error) { + switch colType { + case "int", "uint": + return "INTEGER", nil + case "float": + return "REAL", nil + case "string", "locstring": + return "TEXT", nil + default: + return "", fmt.Errorf("unsupported type: %s", colType) + } +} diff --git a/tools/db2tool/sqlite/sqlite_test.go b/tools/db2tool/sqlite/sqlite_test.go new file mode 100644 index 0000000000..b293ea95df --- /dev/null +++ b/tools/db2tool/sqlite/sqlite_test.go @@ -0,0 +1,134 @@ +package sqlite + +import ( + "database/sql" + "path/filepath" + "testing" + + "github.com/wowsims/mop/tools/db2tool/dbd" + "github.com/wowsims/mop/tools/db2tool/wdc" + _ "modernc.org/sqlite" +) + +// modernc driver-marshaling smoke test (plan §8 step 7): creates the §5.2/§5.3 +// schema shapes, upserts via named params, and reads back json_extract virtual +// columns, NULL scans, and REAL vs INTEGER marshaling — a permanent regression +// gate for driver parity, independent of any game-data snapshot. +func TestModerncMarshalingContract(t *testing.T) { + path := filepath.Join(t.TempDir(), "smoke.db") + + def := dbd.DBDefinition{ + ColumnDefinitions: map[string]dbd.ColumnDefinition{ + "ID": {Type: "int"}, + "Name": {Type: "locstring"}, + "Rate": {Type: "float"}, + "Stats": {Type: "int"}, + "Scales": {Type: "float"}, + "ParentID": {Type: "int", ForeignTable: "Other", ForeignColumn: "ID"}, + }, + } + version := dbd.VersionDefinitions{ + Definitions: []dbd.Definition{ + {Name: "ID", Size: 32, IsID: true, IsSigned: true}, + {Name: "Name"}, + {Name: "Rate"}, + {Name: "Stats", Size: 32, ArrLength: 3, IsSigned: true}, + {Name: "Scales", ArrLength: 2}, + {Name: "ParentID", Size: 32, IsSigned: true, IsRelation: true}, + }, + } + td := TableDef{Name: "Smoke", Def: def, Version: version} + + db, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if err := CreateTables(db, []TableDef{td}); err != nil { + t.Fatal(err) + } + + decoded := &wdc.Decoded{ + ColumnNames: []string{"ID", "Name", "Rate", "Stats", "Scales", "ParentID"}, + Rows: []wdc.Row{ + {ID: 1, Values: []any{int64(1), "first", float32(0.581), []int64{1, -2, 3}, []float32{0.1, 0}, int64(0)}}, + {ID: 2, Values: []any{int64(2), "", float32(0), []int64{0, 0, 0}, []float32{0, 0}, int64(7)}}, + }, + } + if err := InsertRows(db, td, decoded); err != nil { + t.Fatal(err) + } + // Upsert (same PK) must update, not duplicate. + if err := InsertRows(db, td, &wdc.Decoded{ColumnNames: decoded.ColumnNames, Rows: []wdc.Row{ + {ID: 2, Values: []any{int64(2), "second", float32(1.5), []int64{9, 9, 9}, []float32{2.5, 0}, int64(7)}}, + }}); err != nil { + t.Fatal(err) + } + + var n int + if err := db.QueryRow("SELECT count(*) FROM Smoke").Scan(&n); err != nil { + t.Fatal(err) + } + if n != 2 { + t.Fatalf("expected 2 rows after upsert, got %d", n) + } + + // float32 scalar must store the double-widened value. + var rate float64 + if err := db.QueryRow("SELECT Rate FROM Smoke WHERE ID=1").Scan(&rate); err != nil { + t.Fatal(err) + } + if rate != float64(float32(0.581)) { + t.Errorf("Rate = %v, want double-widened float32 %v", rate, float64(float32(0.581))) + } + + // Arrays: exact JSON text and virtual-column extraction (int and float). + var statsText string + var stats1 int + var scales0 float64 + if err := db.QueryRow("SELECT Stats, Stats_1, Scales_0 FROM Smoke WHERE ID=1").Scan(&statsText, &stats1, &scales0); err != nil { + t.Fatal(err) + } + if statsText != "[1,-2,3]" { + t.Errorf("Stats text = %q, want [1,-2,3]", statsText) + } + if stats1 != -2 { + t.Errorf("Stats_1 = %d, want -2", stats1) + } + // json_extract parses the stored float32 shortest-round-trip TEXT ("0.1") + // as a double — so virtual float columns yield 0.1, NOT the widened + // float32 0.10000000149011612. The C# reference behaves identically. + if scales0 != 0.1 { + t.Errorf("Scales_0 = %v, want 0.1", scales0) + } + + // All-zero arrays serialize as [0,...], never NULL/[]/"" (§5.5). + var zeroStats, zeroScales string + if err := db.QueryRow("SELECT Stats, Scales FROM Smoke WHERE ID=2").Scan(&zeroStats, &zeroScales); err != nil { + t.Fatal(err) + } + if zeroStats != "[9,9,9]" || zeroScales != "[2.5,0]" { + t.Errorf("upserted arrays = %q / %q, want [9,9,9] / [2.5,0]", zeroStats, zeroScales) + } + + // Relation value 0 stays 0 — never converted to NULL (§5.4). + var parent sql.NullInt64 + if err := db.QueryRow("SELECT ParentID FROM Smoke WHERE ID=1").Scan(&parent); err != nil { + t.Fatal(err) + } + if !parent.Valid || parent.Int64 != 0 { + t.Errorf("ParentID = %+v, want valid 0", parent) + } + + // Schema shape: FK index + relation index + PK + generated columns exist. + for _, wantIdx := range []string{"IX_Smoke_ParentID", "idx_parentid"} { + var cnt int + if err := db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='index' AND name=?", wantIdx).Scan(&cnt); err != nil { + t.Fatal(err) + } + if cnt != 1 { + t.Errorf("index %s missing", wantIdx) + } + } +} diff --git a/tools/db2tool/wdc/bitreader.go b/tools/db2tool/wdc/bitreader.go new file mode 100644 index 0000000000..d2c75ba850 --- /dev/null +++ b/tools/db2tool/wdc/bitreader.go @@ -0,0 +1,89 @@ +// Go translation of DBCD.IO's BitReader (https://github.com/wowdev/DBCD, +// v2.1.2, commit 2180edb4d08b3822b3cfa964293ba8ccd4236ac0). +// Copyright (c) 2020 wowdev. MIT License — see tools/db2tool/NOTICES.md. +package wdc + +import ( + "encoding/binary" + "math" +) + +// bitReader reads unaligned little-endian bit windows exactly like the C# +// BitReader: a raw 4/8-byte load at the current byte, shifted left then right +// to isolate numBits. The C# code performs unchecked past-the-end loads (the +// reader pads record buffers with 8 zero bytes); newBitReader enforces the +// same padding so Go slice bounds are never exceeded. C# shift counts are +// masked (&31 / &63) by the CLR; the same masking is applied here so behavior +// is bug-for-bug identical even for degenerate widths. +type bitReader struct { + data []byte + Position int // in bits, relative to Offset + Offset int // in bytes +} + +// newBitReader wraps data that MUST already include 8 bytes of zero padding +// beyond the last meaningful byte (see padRecordData). +func newBitReader(data []byte) *bitReader { + return &bitReader{data: data} +} + +// padRecordData appends 8 zero bytes, mirroring WDC5Reader's +// Array.Resize(ref data, data.Length + 8) and making unaligned loads at the +// tail safe. The extra bytes are always masked out of results. +func padRecordData(data []byte) []byte { + // Must copy: data may alias the file buffer, and appending in place would + // overwrite the bytes that follow the record block. + out := make([]byte, len(data)+8) + copy(out, data) + return out +} + +func (r *bitReader) ReadUInt32(numBits int) uint32 { + v := binary.LittleEndian.Uint32(r.data[r.Offset+(r.Position>>3):]) + result := v << ((32 - numBits - (r.Position & 7)) & 31) >> ((32 - numBits) & 31) + r.Position += numBits + return result +} + +func (r *bitReader) ReadUInt64(numBits int) uint64 { + v := binary.LittleEndian.Uint64(r.data[r.Offset+(r.Position>>3):]) + result := v << ((64 - numBits - (r.Position & 7)) & 63) >> ((64 - numBits) & 63) + r.Position += numBits + return result +} + +// ReadValue64 returns the raw (zero-extended) bits; the caller reinterprets +// them per the DBD-declared field type (value64 semantics). +func (r *bitReader) ReadValue64(numBits int) uint64 { + return r.ReadUInt64(numBits) +} + +// ReadValue64Signed sign-extends a numBits-wide value to 64 bits. +func (r *bitReader) ReadValue64Signed(numBits int) uint64 { + result := r.ReadUInt64(numBits) + signedShift := uint64(1) << ((numBits - 1) & 63) + return (signedShift ^ result) - signedShift +} + +func (r *bitReader) ReadCString() string { + var bytes []byte + for { + num := r.ReadUInt32(8) + if num == 0 { + break + } + bytes = append(bytes, byte(num)) + } + return string(bytes) +} + +func (r *bitReader) clone() *bitReader { + return &bitReader{data: r.data} +} + +// value32 mirrors C# Value32: 4 raw bytes reinterpreted on demand. +type value32 uint32 + +func (v value32) Float32() float32 { return math.Float32frombits(uint32(v)) } +func (v value32) Int32() int32 { return int32(v) } +func (v value32) Uint32() uint32 { return uint32(v) } diff --git a/tools/db2tool/wdc/row.go b/tools/db2tool/wdc/row.go new file mode 100644 index 0000000000..d8faf7ee8e --- /dev/null +++ b/tools/db2tool/wdc/row.go @@ -0,0 +1,418 @@ +// Go translation of DBCD.IO's WDC4Row (the row class WDC5Reader actually +// instantiates) plus the DBCDBuilder DBD-to-field-type mapping and the +// BaseReader copy-row semantics (https://github.com/wowdev/DBCD, v2.1.2, +// commit 2180edb4d08b3822b3cfa964293ba8ccd4236ac0). +// Copyright (c) 2020 wowdev. MIT License — see tools/db2tool/NOTICES.md. +package wdc + +import ( + "fmt" + "math" + "sort" + + "github.com/wowsims/mop/tools/db2tool/dbd" +) + +type colKind int + +const ( + kindInt colKind = iota + kindFloat + kindString // string, or locstring when locStringSize == 1 (always true for MoP builds) +) + +// fieldPlan is the precomputed per-definition decode plan, mirroring what +// DBCDBuilder encodes into the dynamic type's fields. +type fieldPlan struct { + name string + kind colKind + size int // int bit width from the DBD (8/16/32/64); 0 for float/string + signed bool + arrLength int + isNonInlineRel bool + isNonInlineID bool + isID bool +} + +// Row is one decoded record; Values align 1:1 with Decoded.ColumnNames. +// Value dynamic types (chosen so encoding/json output matches what C# +// System.Text.Json emits for a value declared as Array — boxed numeric +// elements, no byte[]→base64 special case): int64/uint64 scalars, float32, +// string, []int64, []uint64, []float32, []string. +type Row struct { + ID int32 + Values []any +} + +type Decoded struct { + ColumnNames []string + Rows []Row // ascending ID (Storage is a SortedDictionary) +} + +func buildFieldPlans(def dbd.DBDefinition, version dbd.VersionDefinitions, buildNumber uint32) ([]fieldPlan, error) { + // DBCDBuilder.GetLocStringSize: 1 for post-wotlk (expansion >= 4 || build > + // 12340) — always the case for the builds this tool targets. A locstring + // therefore maps to a single string field with no _mask column. + if buildNumber <= 12340 { + return nil, fmt.Errorf("build %d predates single-locale locstrings; this port only supports locStringSize == 1", buildNumber) + } + + plans := make([]fieldPlan, len(version.Definitions)) + for i, d := range version.Definitions { + col, ok := def.ColumnDefinitions[d.Name] + if !ok { + return nil, fmt.Errorf("column definition for %q not found", d.Name) + } + p := fieldPlan{ + name: d.Name, + arrLength: d.ArrLength, + isNonInlineRel: d.IsRelation && d.IsNonInline, + isNonInlineID: d.IsID && d.IsNonInline, + isID: d.IsID, + } + switch col.Type { + case "int", "uint": + p.kind = kindInt + p.size = d.Size + p.signed = d.IsSigned + switch d.Size { + case 8, 16, 32, 64: + default: + return nil, fmt.Errorf("column %q: unsupported int size %d", d.Name, d.Size) + } + case "float": + p.kind = kindFloat + case "string", "locstring": + p.kind = kindString + default: + return nil, fmt.Errorf("column %q: unable to construct field type from %q", d.Name, col.Type) + } + // DBCDBuilder: a non-inline relation is always typeof(int), regardless + // of the DBD-declared type. + if p.isNonInlineRel { + p.kind = kindInt + p.size = 32 + p.signed = true + } + plans[i] = p + } + return plans, nil +} + +// DecodeRows decodes every record (including copy-table duplicates) into +// values aligned with version.Definitions, returned in ascending-ID order. +func (t *Table) DecodeRows(def dbd.DBDefinition, version dbd.VersionDefinitions, buildNumber uint32) (*Decoded, error) { + plans, err := buildFieldPlans(def, version, buildNumber) + if err != nil { + return nil, err + } + + columnNames := make([]string, len(plans)) + for i, p := range plans { + columnNames[i] = p.name + } + + byID := make(map[int32][]any, len(t.rows)) + + hadInlineID := false + for _, row := range t.rows { + id, values, err := t.decodeRow(row, plans) + if err != nil { + return nil, fmt.Errorf("record %d: %w", row.recordIndex, err) + } + if row.id == -1 { + hadInlineID = true + } + if _, dup := byID[id]; dup { + return nil, fmt.Errorf("duplicate row id %d", id) + } + byID[id] = values + } + + // Copy-table rows: clone the source row's decoded values and rewrite the + // id field (BaseReader.GetCopyRows + WDC4Row.GetFields on the clone). + if len(t.copyData) > 0 { + if hadInlineID { + // C# re-decodes clones with an off-by-one field mapping in this + // case; it never occurs on real data. Refuse rather than diverge. + return nil, fmt.Errorf("copy table present on a table with inline ids — unsupported (would diverge from C# behavior)") + } + idFieldIndex := int(t.IdFieldIndex) + if idFieldIndex >= len(plans) { + return nil, fmt.Errorf("IdFieldIndex %d out of range for %d definitions", idFieldIndex, len(plans)) + } + for _, ce := range t.copyData { + src, ok := byID[ce.Src] + if !ok { + return nil, fmt.Errorf("copy-table source row %d not found (dest %d)", ce.Src, ce.Dest) + } + if _, dup := byID[ce.Dest]; dup { + return nil, fmt.Errorf("duplicate row id %d from copy table", ce.Dest) + } + values := make([]any, len(src)) + copy(values, src) + values[idFieldIndex] = int64(ce.Dest) + byID[ce.Dest] = values + } + } + + ids := make([]int32, 0, len(byID)) + for id := range byID { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + + decoded := &Decoded{ColumnNames: columnNames, Rows: make([]Row, len(ids))} + for i, id := range ids { + decoded.Rows[i] = Row{ID: id, Values: byID[id]} + } + return decoded, nil +} + +// decodeRow ports WDC4Row.GetFields. +func (t *Table) decodeRow(row rawRow, plans []fieldPlan) (int32, []any, error) { + r := row.data + r.Position = row.dataPos + r.Offset = row.dataOffset + + id := row.id + values := make([]any, len(plans)) + indexFieldOffset := 0 + + for i, p := range plans { + if i == int(t.IdFieldIndex) { + if id != -1 { + indexFieldOffset++ + } else { + raw, err := t.getFieldRaw(0, r, i) + if err != nil { + return 0, nil, fmt.Errorf("field %s: %w", p.name, err) + } + id = int32(uint32(raw)) + } + values[i] = int64(id) + continue + } + + fieldIndex := i - indexFieldOffset + + if fieldIndex >= len(t.Meta) { + // Trailing non-inline relation: the parent-lookup refID. + values[i] = int64(row.refID) + continue + } + + var err error + if p.arrLength != 0 { + values[i], err = t.readArrayField(r, fieldIndex, p, row) + } else { + values[i], err = t.readScalarField(id, r, fieldIndex, p, row) + } + if err != nil { + return 0, nil, fmt.Errorf("field %s: %w", p.name, err) + } + } + + return id, values, nil +} + +func (t *Table) readScalarField(id int32, r *bitReader, fieldIndex int, p fieldPlan, row rawRow) (any, error) { + if p.kind == kindString { + if t.Flags&flagSparse != 0 { + return r.ReadCString(), nil + } + // getStringTableRecord: the byte position is captured BEFORE the + // relative offset is read (C# left-to-right evaluation). + recordOffset := (int(row.recordIndex) * int(t.RecordSize)) - (int(t.RecordsCount) * int(t.RecordSize)) + bytePos := r.Position >> 3 + raw, err := t.getFieldRaw(id, r, fieldIndex) + if err != nil { + return nil, err + } + index := recordOffset + bytePos + int(int32(uint32(raw))) + if index < 0 { + index = 0 + } + s, ok := t.StringTable[int64(index)] + if !ok { + return nil, fmt.Errorf("string table miss at offset %d", index) + } + return s, nil + } + + raw, err := t.getFieldRaw(id, r, fieldIndex) + if err != nil { + return nil, err + } + if p.kind == kindFloat { + return math.Float32frombits(uint32(raw)), nil + } + return rawToInt(raw, p.size, p.signed), nil +} + +// rawToInt reinterprets the low bits of the 64-bit read per the DBD-declared +// width and signedness (Value64.GetValue semantics). Unsigned 64-bit stays +// uint64; every other case fits int64. +func rawToInt(raw uint64, size int, signed bool) any { + switch size { + case 8: + if signed { + return int64(int8(raw)) + } + return int64(uint8(raw)) + case 16: + if signed { + return int64(int16(raw)) + } + return int64(uint16(raw)) + case 32: + if signed { + return int64(int32(raw)) + } + return int64(uint32(raw)) + case 64: + if signed { + return int64(raw) + } + return raw + } + panic(fmt.Sprintf("unsupported int size %d", size)) // guarded in buildFieldPlans +} + +func (t *Table) readArrayField(r *bitReader, fieldIndex int, p fieldPlan, row rawRow) (any, error) { + fm := t.Meta[fieldIndex] + cm := t.ColumnMeta[fieldIndex] + + if p.kind == kindString { + if t.Flags&flagSparse != 0 { + // C# WDC4Row routes string[] to GetFieldValueStringArray, which has + // no sparse path; no configured table has string arrays. + return nil, fmt.Errorf("string arrays in sparse tables are not supported") + } + if cm.CompressionType != compressionNone { + return nil, fmt.Errorf("unexpected compression type %d for string array", cm.CompressionType) + } + bitSize := 32 - int(fm.Bits) + if bitSize <= 0 { + bitSize = int(cm.B) + } + count := int(cm.Size) / 32 + recordOffset := (int(row.recordIndex) * int(t.RecordSize)) - (int(t.RecordsCount) * int(t.RecordSize)) + out := make([]string, count) + for i := range out { + bytePos := r.Position >> 3 + raw := r.ReadValue64(bitSize) + index := bytePos + recordOffset + int(int32(uint32(raw))) + if index < 0 { + index = 0 + } + s, ok := t.StringTable[int64(index)] + if !ok { + return nil, fmt.Errorf("string table miss at offset %d", index) + } + out[i] = s + } + return out, nil + } + + elemBits := 32 + if p.kind == kindInt { + elemBits = p.size + } + + var raws []uint64 + switch cm.CompressionType { + case compressionNone: + bitSize := 32 - int(fm.Bits) + if bitSize <= 0 { + bitSize = int(cm.B) + } + count := int(cm.Size) / elemBits + raws = make([]uint64, count) + for i := range raws { + raws[i] = r.ReadValue64(bitSize) + } + case compressionPalletArray: + cardinality := int(cm.C) + idx := int(r.ReadUInt32(int(cm.B))) + pallet := t.PalletData[fieldIndex] + raws = make([]uint64, cardinality) + for i := range raws { + pi := i + cardinality*idx + if pi < 0 || pi >= len(pallet) { + return nil, fmt.Errorf("pallet-array index %d out of range (%d entries)", pi, len(pallet)) + } + raws[i] = uint64(uint32(t.PalletData[fieldIndex][pi])) + } + default: + return nil, fmt.Errorf("unexpected compression type %d for array field", cm.CompressionType) + } + + if p.kind == kindFloat { + out := make([]float32, len(raws)) + for i, raw := range raws { + out[i] = math.Float32frombits(uint32(raw)) + } + return out, nil + } + + // C# serializes these through SqliteDataInserter's `value is Array arr → + // JsonSerializer.Serialize(arr)`, whose declared type is Array: STJ takes + // the IEnumerable path and writes each element as a boxed number. That + // means byte[] serializes as [0,0,0] here, NOT base64 (verified against + // the reference DB), so plain numeric slices reproduce the text exactly. + // Width/sign truncation still follows the DBD-declared element type. + if p.size == 64 && !p.signed { + out := make([]uint64, len(raws)) + copy(out, raws) + return out, nil + } + out := make([]int64, len(raws)) + for i, raw := range raws { + out[i] = rawToInt(raw, p.size, p.signed).(int64) + } + return out, nil +} + +// getFieldRaw ports GetFieldValue's compression dispatch, returning the +// raw 64-bit value before type reinterpretation. +func (t *Table) getFieldRaw(id int32, r *bitReader, fieldIndex int) (uint64, error) { + fm := t.Meta[fieldIndex] + cm := t.ColumnMeta[fieldIndex] + + switch cm.CompressionType { + case compressionNone: + bitSize := 32 - int(fm.Bits) + if bitSize <= 0 { + bitSize = int(cm.B) // Immediate.BitWidth + } + return r.ReadValue64(bitSize), nil + case compressionSignedImmediate: + return r.ReadValue64Signed(int(cm.B)), nil + case compressionImmediate: + return r.ReadValue64(int(cm.B)), nil + case compressionCommon: + if v, ok := t.CommonData[fieldIndex][id]; ok { + return uint64(uint32(v)), nil + } + return uint64(uint32(cm.A)), nil // Common.DefaultValue raw bytes + case compressionPallet: + idx := int(r.ReadUInt32(int(cm.B))) + pallet := t.PalletData[fieldIndex] + if idx < 0 || idx >= len(pallet) { + return 0, fmt.Errorf("pallet index %d out of range (%d entries)", idx, len(pallet)) + } + return uint64(uint32(pallet[idx])), nil + case compressionPalletArray: + if cm.C != 1 { // Pallet.Cardinality + return 0, fmt.Errorf("unexpected compression type %d (pallet-array cardinality %d on scalar field)", cm.CompressionType, cm.C) + } + idx := int(r.ReadUInt32(int(cm.B))) + pallet := t.PalletData[fieldIndex] + if idx < 0 || idx >= len(pallet) { + return 0, fmt.Errorf("pallet-array index %d out of range (%d entries)", idx, len(pallet)) + } + return uint64(uint32(pallet[idx])), nil + } + return 0, fmt.Errorf("unexpected compression type %d", cm.CompressionType) +} diff --git a/tools/db2tool/wdc/wdc5.go b/tools/db2tool/wdc/wdc5.go new file mode 100644 index 0000000000..2d0c530ed2 --- /dev/null +++ b/tools/db2tool/wdc/wdc5.go @@ -0,0 +1,577 @@ +// Go translation of DBCD.IO's WDC5Reader (https://github.com/wowdev/DBCD, +// v2.1.2, commit 2180edb4d08b3822b3cfa964293ba8ccd4236ac0), including its +// encrypted-section skip path (no TACT keys — plan §7 C1). +// Copyright (c) 2020 wowdev. MIT License — see tools/db2tool/NOTICES.md. +package wdc + +import ( + "encoding/binary" + "fmt" + "os" + "strings" +) + +const wdc5Magic = "WDC5" +const headerSize = 200 + +type db2Flags uint16 + +const ( + flagSparse db2Flags = 0x1 + flagSecondaryKey db2Flags = 0x2 + flagIndex db2Flags = 0x4 +) + +const ( + compressionNone = 0 + compressionImmediate = 1 + compressionCommon = 2 + compressionPallet = 3 + compressionPalletArray = 4 + compressionSignedImmediate = 5 +) + +type fieldMeta struct { + Bits int16 + Offset int16 +} + +// columnMeta mirrors ColumnMetaData; A/B/C are the 12-byte union: +// Immediate{BitOffset,BitWidth,Flags} / Pallet{BitOffset,BitWidth,Cardinality} / +// Common{DefaultValue,B,C}. +type columnMeta struct { + RecordOffset uint16 + Size uint16 + AdditionalDataSize uint32 + CompressionType uint32 + A, B, C int32 +} + +type sectionHeader struct { + TactKeyLookup uint64 + FileOffset int32 + NumRecords int32 + StringTableSize int32 + OffsetRecordsEndOffset int32 + IndexDataSize int32 + ParentLookupDataSize int32 + OffsetMapIDCount int32 + CopyTableCount int32 +} + +type sparseEntry struct { + Offset uint32 + Size uint16 +} + +// rawRow is a not-yet-decoded record: a bit reader positioned at its data, +// plus the identity WDC4Row captures at construction. +type rawRow struct { + data *bitReader + dataOffset int + dataPos int + id int32 // -1 when the id is inline in record data + refID int32 + recordIndex int32 +} + +type copyEntry struct { + Dest int32 + Src int32 +} + +// Table is the parsed (but not field-decoded) WDC5 file. +type Table struct { + SchemaVersion uint32 + SchemaString string + RecordsCount int32 + FieldsCount int32 + RecordSize int32 + StringTableSize int32 + TableHash uint32 + LayoutHash uint32 + MinIndex int32 + MaxIndex int32 + Locale int32 + Flags db2Flags + IdFieldIndex uint16 + + Sections []sectionHeader + Meta []fieldMeta + ColumnMeta []columnMeta + PalletData [][]value32 + CommonData []map[int32]value32 + + StringTable map[int64]string + + rows []rawRow + copyData []copyEntry // file order; dest==src entries already dropped + + // SkippedSections counts encrypted sections whose data was zero-filled + // and therefore skipped (diagnostics for the golden harness). + SkippedSections int +} + +type cursor struct { + buf []byte + pos int +} + +func (c *cursor) need(n int) ([]byte, error) { + if c.pos+n > len(c.buf) { + return nil, fmt.Errorf("unexpected EOF: need %d bytes at offset %d, file is %d bytes", n, c.pos, len(c.buf)) + } + b := c.buf[c.pos : c.pos+n] + c.pos += n + return b, nil +} + +func (c *cursor) u16() (uint16, error) { + b, err := c.need(2) + if err != nil { + return 0, err + } + return binary.LittleEndian.Uint16(b), nil +} + +func (c *cursor) i32() (int32, error) { + b, err := c.need(4) + if err != nil { + return 0, err + } + return int32(binary.LittleEndian.Uint32(b)), nil +} + +func (c *cursor) u32() (uint32, error) { + b, err := c.need(4) + if err != nil { + return 0, err + } + return binary.LittleEndian.Uint32(b), nil +} + +func (c *cursor) u64() (uint64, error) { + b, err := c.need(8) + if err != nil { + return 0, err + } + return binary.LittleEndian.Uint64(b), nil +} + +// ReadFile parses a WDC5 .db2 file. Only WDC5 is supported; anything else +// (including WDC6+) fails loud, matching the plan's format stance. +func ReadFile(path string) (*Table, error) { + buf, err := os.ReadFile(path) + if err != nil { + return nil, err + } + t, err := read(buf) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + return t, nil +} + +func read(buf []byte) (*Table, error) { + if len(buf) < headerSize { + return nil, fmt.Errorf("WDC5 file is corrupted (shorter than %d-byte header)", headerSize) + } + if string(buf[:4]) != wdc5Magic { + return nil, fmt.Errorf("unsupported DB2 format %q (only WDC5 is supported)", string(buf[:4])) + } + + c := &cursor{buf: buf, pos: 4} + t := &Table{} + + var err error + if t.SchemaVersion, err = c.u32(); err != nil { + return nil, err + } + schemaBytes, err := c.need(128) + if err != nil { + return nil, err + } + t.SchemaString = strings.TrimRight(string(schemaBytes), "\x00") + + ints := make([]int32, 9) + for i := range ints { + if ints[i], err = c.i32(); err != nil { + return nil, err + } + } + t.RecordsCount, t.FieldsCount, t.RecordSize, t.StringTableSize = ints[0], ints[1], ints[2], ints[3] + t.TableHash, t.LayoutHash = uint32(ints[4]), uint32(ints[5]) + t.MinIndex, t.MaxIndex, t.Locale = ints[6], ints[7], ints[8] + + flags, err := c.u16() + if err != nil { + return nil, err + } + t.Flags = db2Flags(flags) + if t.IdFieldIndex, err = c.u16(); err != nil { + return nil, err + } + + // totalFieldsCount, PackedDataOffset, lookupColumnCount, columnMetaDataSize, + // commonDataSize, palletDataSize, sectionsCount + tail := make([]int32, 7) + for i := range tail { + if tail[i], err = c.i32(); err != nil { + return nil, err + } + } + sectionsCount := int(tail[6]) + + t.Sections = make([]sectionHeader, sectionsCount) + for i := range t.Sections { + s := &t.Sections[i] + if s.TactKeyLookup, err = c.u64(); err != nil { + return nil, err + } + for _, dst := range []*int32{&s.FileOffset, &s.NumRecords, &s.StringTableSize, &s.OffsetRecordsEndOffset, + &s.IndexDataSize, &s.ParentLookupDataSize, &s.OffsetMapIDCount, &s.CopyTableCount} { + if *dst, err = c.i32(); err != nil { + return nil, err + } + } + } + + // C# BinaryReader.ReadBytes tolerates short reads, which matters for the + // empty ItemBonus.db2: its file ends mid-way through the meta blocks, and + // the early return below never consumes them. Mirror that tolerance only + // when the early return will be taken; otherwise a truncated file is + // corrupt and must fail loud. + emptyTable := sectionsCount == 0 || t.RecordsCount == 0 + + t.Meta = make([]fieldMeta, t.FieldsCount) + for i := range t.Meta { + b, err := c.need(4) + if err != nil { + if emptyTable { + t.Meta = t.Meta[:i] + break + } + return nil, err + } + t.Meta[i].Bits = int16(binary.LittleEndian.Uint16(b[0:2])) + t.Meta[i].Offset = int16(binary.LittleEndian.Uint16(b[2:4])) + } + + t.ColumnMeta = make([]columnMeta, t.FieldsCount) + for i := range t.ColumnMeta { + if emptyTable && c.pos+24 > len(c.buf) { + t.ColumnMeta = t.ColumnMeta[:i] + break + } + m := &t.ColumnMeta[i] + if m.RecordOffset, err = c.u16(); err != nil { + return nil, err + } + if m.Size, err = c.u16(); err != nil { + return nil, err + } + if m.AdditionalDataSize, err = c.u32(); err != nil { + return nil, err + } + if m.CompressionType, err = c.u32(); err != nil { + return nil, err + } + if m.A, err = c.i32(); err != nil { + return nil, err + } + if m.B, err = c.i32(); err != nil { + return nil, err + } + if m.C, err = c.i32(); err != nil { + return nil, err + } + } + + // ItemBonus.db2 is empty: 0 sections / 0 records is valid (plan §7 C2). + if emptyTable { + return t, nil + } + + // pallet data + t.PalletData = make([][]value32, len(t.ColumnMeta)) + for i := range t.ColumnMeta { + ct := t.ColumnMeta[i].CompressionType + if ct == compressionPallet || ct == compressionPalletArray { + n := int(t.ColumnMeta[i].AdditionalDataSize / 4) + t.PalletData[i] = make([]value32, n) + for j := 0; j < n; j++ { + v, err := c.u32() + if err != nil { + return nil, err + } + t.PalletData[i][j] = value32(v) + } + } + } + + // common data + t.CommonData = make([]map[int32]value32, len(t.ColumnMeta)) + for i := range t.ColumnMeta { + if t.ColumnMeta[i].CompressionType == compressionCommon { + n := int(t.ColumnMeta[i].AdditionalDataSize / 8) + m := make(map[int32]value32, n) + t.CommonData[i] = m + for j := 0; j < n; j++ { + k, err := c.i32() + if err != nil { + return nil, err + } + v, err := c.u32() + if err != nil { + return nil, err + } + m[k] = value32(v) + } + } + } + + // encrypted ID lists (read sequentially; content unused, like upstream's + // m_encryptedIDs which this tool never consults) + for i := 0; i < sectionsCount; i++ { + if t.Sections[i].TactKeyLookup == 0 { + continue + } + n, err := c.i32() + if err != nil { + return nil, err + } + if _, err := c.need(int(n) * 4); err != nil { + return nil, err + } + } + + t.StringTable = make(map[int64]string) + + previousStringTableSize := int32(0) + previousRecordCount := int32(0) + for si := range t.Sections { + section := t.Sections[si] + c.pos = int(section.FileOffset) + + var recordsData []byte + if t.Flags&flagSparse == 0 { + raw, err := c.need(int(section.NumRecords) * int(t.RecordSize)) + if err != nil { + return nil, err + } + recordsData = padRecordData(raw) + + stringData, err := c.need(int(section.StringTableSize)) + if err != nil { + return nil, err + } + readStringTable(t.StringTable, stringData, int64(previousStringTableSize)) + previousStringTableSize += section.StringTableSize + } else { + raw, err := c.need(int(section.OffsetRecordsEndOffset - section.FileOffset)) + if err != nil { + return nil, err + } + recordsData = padRecordData(raw) + if c.pos != int(section.OffsetRecordsEndOffset) { + return nil, fmt.Errorf("stream position != OffsetRecordsEndOffset") + } + } + + // Skip encrypted sections: TACT key lookup set + record data zero-filled + // (plan §7 C1). The trailing guards mirror WDC5Reader exactly. + if section.TactKeyLookup != 0 && allZero(recordsData) { + completelyZero := false + if section.IndexDataSize > 0 || section.CopyTableCount > 0 { + // Peek the first id from IndexData/CopyData without consuming. + if c.pos+4 > len(c.buf) { + return nil, fmt.Errorf("unexpected EOF peeking encrypted-section id data") + } + completelyZero = binary.LittleEndian.Uint32(c.buf[c.pos:c.pos+4]) == 0 + } else if section.OffsetMapIDCount > 0 { + // Peek the first SparseEntry's Size without consuming. + if c.pos+6 > len(c.buf) { + return nil, fmt.Errorf("unexpected EOF peeking encrypted-section sparse data") + } + completelyZero = binary.LittleEndian.Uint16(c.buf[c.pos+4:c.pos+6]) == 0 + } else { + completelyZero = true + } + if completelyZero { + previousRecordCount += section.NumRecords + t.SkippedSections++ + continue + } + } + + // index data + indexData := make([]int32, section.IndexDataSize/4) + for i := range indexData { + if indexData[i], err = c.i32(); err != nil { + return nil, err + } + } + if len(indexData) > 0 && allZeroInts(indexData) { + for i := range indexData { + indexData[i] = t.MinIndex + previousRecordCount + int32(i) + } + } + + // duplicate rows data + for i := int32(0); i < section.CopyTableCount; i++ { + dest, err := c.i32() + if err != nil { + return nil, err + } + src, err := c.i32() + if err != nil { + return nil, err + } + if dest != src { + t.copyData = append(t.copyData, copyEntry{Dest: dest, Src: src}) + } + } + + var sparseEntries []sparseEntry + if section.OffsetMapIDCount > 0 { + // HACK: upstream skips a malformed unit-test table (hash 145293629). + if t.TableHash == 145293629 { + if _, err := c.need(4 * int(section.OffsetMapIDCount)); err != nil { + return nil, err + } + } + sparseEntries = make([]sparseEntry, section.OffsetMapIDCount) + for i := range sparseEntries { + b, err := c.need(6) + if err != nil { + return nil, err + } + sparseEntries[i].Offset = binary.LittleEndian.Uint32(b[0:4]) + sparseEntries[i].Size = binary.LittleEndian.Uint16(b[4:6]) + } + } + + if section.OffsetMapIDCount > 0 && t.Flags&flagSecondaryKey != 0 { + var err error + indexData, err = readSparseIndexData(c, section, indexData) + if err != nil { + return nil, err + } + } + + // reference (parent lookup) data + refEntries := make(map[int32]int32) + if section.ParentLookupDataSize > 0 { + numRecords, err := c.i32() + if err != nil { + return nil, err + } + if _, err := c.need(8); err != nil { // minId, maxId + return nil, err + } + for i := int32(0); i < numRecords; i++ { + id, err := c.i32() + if err != nil { + return nil, err + } + index, err := c.i32() + if err != nil { + return nil, err + } + refEntries[index] = id + } + } + + if section.OffsetMapIDCount > 0 && t.Flags&flagSecondaryKey == 0 { + var err error + indexData, err = readSparseIndexData(c, section, indexData) + if err != nil { + return nil, err + } + } + + position := 0 + for i := int32(0); i < section.NumRecords; i++ { + br := newBitReader(recordsData) + if t.Flags&flagSparse != 0 { + br.Position = position + position += int(sparseEntries[i].Size) * 8 + } else { + br.Offset = int(i) * int(t.RecordSize) + } + + var refID int32 + if t.Flags&flagSecondaryKey != 0 { + refID = refEntries[indexData[i]] + } else { + refID = refEntries[i] + } + + id := int32(-1) + if section.IndexDataSize != 0 { + id = indexData[i] + } + + t.rows = append(t.rows, rawRow{ + data: br, + dataOffset: br.Offset, + dataPos: br.Position, + id: id, + refID: refID, + recordIndex: i + previousRecordCount, + }) + } + + previousRecordCount += section.NumRecords + } + + return t, nil +} + +func readSparseIndexData(c *cursor, section sectionHeader, indexData []int32) ([]int32, error) { + sparseIndexData := make([]int32, section.OffsetMapIDCount) + for i := range sparseIndexData { + var err error + if sparseIndexData[i], err = c.i32(); err != nil { + return nil, err + } + } + if section.IndexDataSize > 0 && len(indexData) != len(sparseIndexData) { + return nil, fmt.Errorf("IndexData length != sparseIndexData length") + } + return sparseIndexData, nil +} + +// readStringTable ports Extensions.ReadStringTable: NUL-separated UTF-8 +// strings keyed by byte offset (baseOffset + running offset). +func readStringTable(dst map[int64]string, data []byte, baseOffset int64) { + if len(data) == 0 { + return + } + curOfs := 0 + for _, str := range strings.Split(string(data), "\x00") { + if curOfs == len(data) { + break + } + dst[baseOffset+int64(curOfs)] = str + curOfs += len(str) + 1 + } +} + +func allZero(b []byte) bool { + for _, v := range b { + if v != 0 { + return false + } + } + return true +} + +func allZeroInts(v []int32) bool { + for _, x := range v { + if x != 0 { + return false + } + } + return true +} diff --git a/tools/db2tool/wdc/wdc5_test.go b/tools/db2tool/wdc/wdc5_test.go new file mode 100644 index 0000000000..1e09a56422 --- /dev/null +++ b/tools/db2tool/wdc/wdc5_test.go @@ -0,0 +1,123 @@ +package wdc + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/wowsims/mop/tools/db2tool/dbd" +) + +// Pre-port audit fixture (plan §8 step 2), frozen from the build-68571 +// snapshot in tools/DB2ToSqlite/dbfilesclient. Tests skip when the gitignored +// snapshot is absent. +const db2Dir = "../../DB2ToSqlite/dbfilesclient" +const dbdDir = "../../DB2ToSqlite/DBDCache" +const snapshotBuild = 68571 + +func db2Files(t *testing.T) []string { + t.Helper() + entries, err := os.ReadDir(db2Dir) + if os.IsNotExist(err) { + t.Skipf("%s not present (gitignored snapshot); skipping", db2Dir) + } + if err != nil { + t.Fatal(err) + } + var files []string + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".db2") { + files = append(files, e.Name()) + } + } + if len(files) != 72 { + t.Fatalf("expected 72 .db2 files, got %d", len(files)) + } + return files +} + +func TestParseAllHeaders(t *testing.T) { + sectionCounts := map[int]bool{} + for _, name := range db2Files(t) { + table, err := ReadFile(filepath.Join(db2Dir, name)) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + sectionCounts[len(table.Sections)] = true + + base := strings.TrimSuffix(name, ".db2") + switch base { + case "ItemBonus": + if len(table.Sections) != 0 && table.RecordsCount != 0 { + t.Errorf("ItemBonus: expected empty table, got %d sections / %d records", len(table.Sections), table.RecordsCount) + } + case "Spell", "ItemSparse": + if table.Flags != 0x5 { + t.Errorf("%s: Flags = 0x%x, want 0x5 (Sparse|Index)", base, table.Flags) + } + } + if table.Flags&flagSecondaryKey != 0 { + t.Errorf("%s: unexpected SecondaryKey flag", base) + } + } + // Distinct section counts frozen in the plan (§8 step 2), plus 0 for the + // empty ItemBonus and 1 for plain single-section tables. + for _, want := range []int{36, 33, 26, 22, 16, 9, 8, 3, 2, 1, 0} { + if !sectionCounts[want] { + t.Errorf("expected some table to have %d sections", want) + } + } +} + +func TestSpellEffectEncryptedSkip(t *testing.T) { + if _, err := os.Stat(db2Dir); os.IsNotExist(err) { + t.Skip("snapshot not present") + } + table, err := ReadFile(filepath.Join(db2Dir, "SpellEffect.db2")) + if err != nil { + t.Fatal(err) + } + if table.RecordsCount != 142756 { + t.Errorf("SpellEffect header record_count = %d, want 142756", table.RecordsCount) + } + if len(table.Sections) != 36 { + t.Errorf("SpellEffect sections = %d, want 36", len(table.Sections)) + } + if table.SkippedSections != 35 { + t.Errorf("SpellEffect skipped sections = %d, want 35", table.SkippedSections) + } + // C1 exact check: 142756 header records − 136 encrypted = 142620 emitted. + if got := len(table.rows); got != 142620 { + t.Errorf("SpellEffect decoded raw rows = %d, want 142620", got) + } +} + +func TestDecodeAllTables(t *testing.T) { + if _, err := os.Stat(dbdDir); os.IsNotExist(err) { + t.Skip("snapshot not present") + } + for _, name := range db2Files(t) { + base := strings.TrimSuffix(name, ".db2") + table, err := ReadFile(filepath.Join(db2Dir, name)) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + def, err := dbd.ReadFile(filepath.Join(dbdDir, base+".dbd"), true) + if err != nil { + t.Fatalf("%s: %v", base, err) + } + version, err := dbd.SelectVersion(def, snapshotBuild) + if err != nil { + t.Fatalf("%s: %v", base, err) + } + decoded, err := table.DecodeRows(def, version, snapshotBuild) + if err != nil { + t.Fatalf("%s: decode: %v", base, err) + } + if base != "ItemBonus" && len(decoded.Rows) == 0 { + t.Errorf("%s: decoded 0 rows", base) + } + t.Logf("%s: %d rows, %d cols, %d skipped sections", base, len(decoded.Rows), len(decoded.ColumnNames), table.SkippedSections) + } +} From 6944dbb2d48fb0d4934ceace0ee29a9eaa1108b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Hillerstr=C3=B6m?= Date: Thu, 16 Jul 2026 16:31:23 +0200 Subject: [PATCH 2/8] db2tool: gitignore local reference captures (refs/) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 14083e8a78..9f20202c58 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ mop.sln graphify-out .claude CLAUDE.md +tools/db2tool/refs/ From b370ea16c255abae2ef9edc0f0e0fe348e836573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Hillerstr=C3=B6m?= Date: Thu, 16 Jul 2026 16:47:46 +0200 Subject: [PATCH 3/8] db2tool: local CASC extraction, dotnet removed from make db MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new tact/ package reads the full local install — a deliberate behavior change from the CDN-fed .NET tool, proven byte-equivalent (see docs/db2tool-migration-plan.md): - .build.info parse + entry-by-Product + trailing build number - build/CDN config from Data/config (vfs-* lines parsed, never consulted) - local .idx v7 buckets (XOR bucket select, packed archive/offset bits, 30-byte storage frame skip), data.NNN via os.ReadAt - encoding EN table (paged BE binary search, 40-bit sizes) - TSFM root (post-10.1.7 dfVersion 1/2, delta-encoded FDIDs, enUS) - BLTE N/Z; keyless 'E' chunks stay zero-filled (plan §7 C1/M5) - FDID resolution: static path map (80 configured paths) + listfile fallback - listfile.csv + .dbd HTTP fetch/cache (Last-Modified / 24h-mtime rules), with offline fallback to existing copies - main.go: local mode derives the build from .build.info; --build keeps the offline pre-extracted mode; settings-relative paths resolve against tools/db2tool (M4, incl. the raw-TargetDirectory FDID-key carve-out) - makefile: make db / make ptrdb now run the Go tool; the three gen_db listfile.csv literals repoint to tools/db2tool/listfile.csv (§9.2) Pre-flight + gates (live install, wow_classic 5.5.4.68571): - one-line BaseDir patch on the .NET tool with its 1.3GB CDN cache moved aside re-extracted everything byte-identically from local CASC - Go tool, local mode: all 72 .db2 and 8 basestats .txt byte-identical to the CDN-fed originals; wowsims.db vs the fresh no-hotfix dotnet reference: schema identical, 2 residual diff lines (known CurvePoint float-notation case, slack table) - full make db pipeline with no dotnet reproduces the committed assets/database/db.json and leftover_db.json byte-for-byte Reference captures (gitignored tools/db2tool/refs/): wowsims.nohotfix.db, wowsims.hotfix.db, DBCache.68571.bin. Current live hotfix overlay is 7 rows (Spring Panda add, Item 277947 delete, Spell 1298412 description) — the input for the hotfix-overlay commit. --- .gitignore | 3 + makefile | 8 +- tools/database/gen_db/main.go | 2 +- tools/database/gen_protos.go | 2 +- tools/database/tables.go | 2 +- tools/db2tool/NOTICES.md | 2 +- tools/db2tool/dbd/fetch.go | 67 ++++++++++++ tools/db2tool/main.go | 144 ++++++++++++++++++++++---- tools/db2tool/tact/blte.go | 103 +++++++++++++++++++ tools/db2tool/tact/build.go | 134 ++++++++++++++++++++++++ tools/db2tool/tact/buildinfo.go | 85 ++++++++++++++++ tools/db2tool/tact/cascidx.go | 175 ++++++++++++++++++++++++++++++++ tools/db2tool/tact/config.go | 37 +++++++ tools/db2tool/tact/encoding.go | 89 ++++++++++++++++ tools/db2tool/tact/fdid.go | 146 ++++++++++++++++++++++++++ tools/db2tool/tact/listfile.go | 83 +++++++++++++++ tools/db2tool/tact/root.go | 112 ++++++++++++++++++++ 17 files changed, 1167 insertions(+), 27 deletions(-) create mode 100644 tools/db2tool/dbd/fetch.go create mode 100644 tools/db2tool/tact/blte.go create mode 100644 tools/db2tool/tact/build.go create mode 100644 tools/db2tool/tact/buildinfo.go create mode 100644 tools/db2tool/tact/cascidx.go create mode 100644 tools/db2tool/tact/config.go create mode 100644 tools/db2tool/tact/encoding.go create mode 100644 tools/db2tool/tact/fdid.go create mode 100644 tools/db2tool/tact/listfile.go create mode 100644 tools/db2tool/tact/root.go diff --git a/.gitignore b/.gitignore index 9f20202c58..3b24f89a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ graphify-out .claude CLAUDE.md tools/db2tool/refs/ +tools/db2tool/listfile.csv +tools/db2tool/DBDCache/ +tools/db2tool/dbfilesclient/ diff --git a/makefile b/makefile index 97f08ff92d..828ac41020 100644 --- a/makefile +++ b/makefile @@ -248,15 +248,15 @@ CLIENTDATA_OUTPUT := $(shell realpath ./tools/database/wowsims.db) .PHONY: db db: - @echo "Running DB2ToSqlite for clientdata" - cd tools/DB2ToSqlite && dotnet run -- -s $(CLIENTDATA_SETTINGS) --output $(CLIENTDATA_OUTPUT) + @echo "Extracting client data (pure Go)" + go run ./tools/db2tool -s $(CLIENTDATA_SETTINGS) --output $(CLIENTDATA_OUTPUT) @echo "Running DBC generation tool" go run tools/database/gen_db/*.go -outDir=./assets -gen=db .PHONY: ptrdb ptrdb: - @echo "Running DB2ToSqlite for clientdata" - cd tools/DB2ToSqlite && dotnet run -- -s $(CLIENTDATAPTR_SETTINGS) --output $(CLIENTDATA_OUTPUT) + @echo "Extracting client data (pure Go)" + go run ./tools/db2tool -s $(CLIENTDATAPTR_SETTINGS) --output $(CLIENTDATA_OUTPUT) @echo "Running DBC generation tool" go run tools/database/gen_db/*.go -outDir=./assets -gen=db diff --git a/tools/database/gen_db/main.go b/tools/database/gen_db/main.go index 157a52c241..6529156359 100644 --- a/tools/database/gen_db/main.go +++ b/tools/database/gen_db/main.go @@ -150,7 +150,7 @@ func main() { db.Encounters = core.PresetEncounters db.ReforgeStats = reforgeStats.ToProto() - iconsMap, err := database.LoadArtTexturePaths("./tools/DB2ToSqlite/listfile.csv") + iconsMap, err := database.LoadArtTexturePaths("./tools/db2tool/listfile.csv") if err != nil { panic(fmt.Sprintf("Error loading icon paths %v", err)) } diff --git a/tools/database/gen_protos.go b/tools/database/gen_protos.go index 861462c3ef..cb5348845d 100644 --- a/tools/database/gen_protos.go +++ b/tools/database/gen_protos.go @@ -455,7 +455,7 @@ func GenerateProtos(dbcData *dbc.DBC, db *WowDatabase) { allGlyphSpellIds := []*proto.GlyphID{} var classesData []ClassData - iconsMap, _ := LoadArtTexturePaths("./tools/DB2ToSqlite/listfile.csv") + iconsMap, _ := LoadArtTexturePaths("./tools/db2tool/listfile.csv") for _, dbcClass := range dbc.Classes { className := dbc.ClassNameFromDBC(dbcClass) data := ClassData{ diff --git a/tools/database/tables.go b/tools/database/tables.go index f031e0cd5e..629ba25e86 100644 --- a/tools/database/tables.go +++ b/tools/database/tables.go @@ -1163,7 +1163,7 @@ LEFT JOIN SpellName sn ON sn.ID = sm.SpellID return iconsByID, nil } -var iconsMap, _ = LoadArtTexturePaths("./tools/DB2ToSqlite/listfile.csv") +var iconsMap, _ = LoadArtTexturePaths("./tools/db2tool/listfile.csv") func ScanSpells(rows *sql.Rows) (dbc.Spell, error) { var spell dbc.Spell diff --git a/tools/db2tool/NOTICES.md b/tools/db2tool/NOTICES.md index 40f8ccdd3a..e49b23af64 100644 --- a/tools/db2tool/NOTICES.md +++ b/tools/db2tool/NOTICES.md @@ -9,7 +9,7 @@ the authoritative list of upstreams, licenses, and pinned revisions. |---|---|---|---| | `wdc/` | [wowdev/DBCD](https://github.com/wowdev/DBCD) (DBCD + DBCD.IO, v2.1.2 — the version vendored as DLLs in `tools/DB2ToSqlite/references/`) | MIT, Copyright (c) 2020 wowdev | `2180edb4d08b3822b3cfa964293ba8ccd4236ac0` | | `dbd/` | [wowdev/WoWDBDefs](https://github.com/wowdev/WoWDBDefs) `code/C#/DBDefsLib` (**code** is BSD-3-Clause; the `.dbd` **data** files are CC BY-SA 4.0 and are fetched at build time, never vendored) | BSD-3-Clause, Copyright 2022 WoWDBDefs Contributors | `9002c532853a96d631c76dda50cb20189c27a173` (master at port time; the vendored DBDefsLib.dll is v1.0.0 with no embedded commit) | -| `tact/` | [wowdev/TACTSharp](https://github.com/wowdev/TACTSharp) v0.0.13-alpha | MIT | `d0ab516eb98b5db35682467b6e4977d88955046d` | +| `tact/` | [wowdev/TACTSharp](https://github.com/wowdev/TACTSharp) v0.0.13-alpha | MIT, Copyright (c) 2024 Martin Benjamins | `d0ab516eb98b5db35682467b6e4977d88955046d` | | `sqlite/`, `config/`, `main.go` | original repo code (ports of this repo's own `tools/DB2ToSqlite/Helpers/*.cs` and `Program.cs`) | repo MIT | — | Runtime data dependencies (fetched, never vendored — see §4 of diff --git a/tools/db2tool/dbd/fetch.go b/tools/db2tool/dbd/fetch.go new file mode 100644 index 0000000000..e976d181d1 --- /dev/null +++ b/tools/db2tool/dbd/fetch.go @@ -0,0 +1,67 @@ +// Fetch-and-cache for .dbd definitions, mirroring DBCD's GithubDBDProvider +// (https://github.com/wowdev/DBCD, MIT): fetch from WoWDBDefs master into a +// gitignored cache directory with a 24h-mtime freshness rule. The .dbd files +// themselves are CC BY-SA 4.0 DATA and are deliberately cached, never +// vendored (plan §4). +package dbd + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" +) + +const dbdURLFormat = "https://raw.githubusercontent.com/wowdev/WoWDBDefs/master/definitions/%s.dbd" + +// FetchCached returns the path to a cached .dbd for tableName under cacheDir, +// fetching from WoWDBDefs when the cached copy is absent or older than 24h. +// On a failed refresh of an existing copy, the stale copy is used (matching +// the provider's tolerance); a missing copy that cannot be fetched is fatal. +func FetchCached(cacheDir, tableName string) (string, error) { + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return "", err + } + path := filepath.Join(cacheDir, tableName+".dbd") + info, statErr := os.Stat(path) + if statErr == nil && time.Since(info.ModTime()) < 24*time.Hour { + return path, nil + } + url := fmt.Sprintf(dbdURLFormat, tableName) + if err := download(url, path); err != nil { + if statErr == nil { + fmt.Fprintf(os.Stderr, "db2tool: refresh of %s failed (%v), using cached copy\n", tableName+".dbd", err) + return path, nil + } + return "", fmt.Errorf("fetching %s: %w", url, err) + } + return path, nil +} + +func download(url, path string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %s", resp.Status) + } + tmp := path + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return err + } + if _, err := io.Copy(f, resp.Body); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, path) +} diff --git a/tools/db2tool/main.go b/tools/db2tool/main.go index 4ff9c8fadd..3e14b77ef0 100644 --- a/tools/db2tool/main.go +++ b/tools/db2tool/main.go @@ -1,11 +1,13 @@ // db2tool extracts World of Warcraft client data into tools/database/wowsims.db, // replacing the .NET tools/DB2ToSqlite tool (see docs/db2tool-migration-plan.md). // -// Phase A form: decodes pre-extracted .db2 files (default: -// tools/DB2ToSqlite/dbfilesclient) against cached .dbd definitions (default: -// tools/DB2ToSqlite/DBDCache) for an explicit --build number. Local CASC -// extraction (Phase B) will replace the pre-extracted inputs and derive the -// build from the install's .build.info. +// Default (Phase B) mode reads the local install named by the settings' +// BaseDir: .build.info picks the build, files come from local CASC +// (root → encoding → .idx → data.NNN → BLTE), .dbd definitions and the +// community listfile are fetched/cached over plain HTTPS. +// +// With --build (and optionally --db2dir/--dbddir), the offline Phase A mode +// decodes pre-extracted .db2 files instead — no install required. package main import ( @@ -16,6 +18,7 @@ import ( "github.com/wowsims/mop/tools/db2tool/config" "github.com/wowsims/mop/tools/db2tool/dbd" "github.com/wowsims/mop/tools/db2tool/sqlite" + "github.com/wowsims/mop/tools/db2tool/tact" "github.com/wowsims/mop/tools/db2tool/wdc" _ "modernc.org/sqlite" ) @@ -30,19 +33,17 @@ func main() { type options struct { settingsFile string databaseFile string - db2Dir string - dbdDir string - buildNumber uint32 + db2Dir string // offline mode only + dbdDir string // offline mode override + buildNumber uint32 // nonzero → offline mode } // parseArgs mirrors Program.cs's pairwise scan, including the flag aliases -// (--settings/-s, --output/-output/-o) plus the Phase A-only flags. +// (--settings/-s, --output/-output/-o), plus the offline-mode flags. func parseArgs(args []string) (options, error) { opts := options{ settingsFile: "appsettings.json", databaseFile: "wowsims.db", - db2Dir: "tools/DB2ToSqlite/dbfilesclient", - dbdDir: "tools/DB2ToSqlite/DBDCache", } for i := 0; i < len(args); i++ { next := func() (string, error) { @@ -81,16 +82,29 @@ func parseArgs(args []string) (options, error) { return opts, nil } +// resolvePath resolves a possibly-relative settings path against the tool +// home directory tools/db2tool (plan §7 M4). Relative settings values like +// "../../assets/db_inputs/basestats" were written for a CWD of +// tools/DB2ToSqlite; tools/db2tool sits at the same depth, so they keep +// meaning what they always meant. +func resolvePath(toolHome, value string) string { + if filepath.IsAbs(value) { + return value + } + return filepath.Join(toolHome, value) +} + func run(args []string) error { opts, err := parseArgs(args) if err != nil { return err } - if opts.buildNumber == 0 { - return fmt.Errorf("--build is required in the Phase A driver (later derived from .build.info)") - } - settings, err := config.Load(opts.settingsFile) + settingsAbs, err := filepath.Abs(opts.settingsFile) + if err != nil { + return err + } + settings, err := config.Load(settingsAbs) if err != nil { return fmt.Errorf("loading settings: %w", err) } @@ -98,6 +112,94 @@ func run(args []string) error { return fmt.Errorf("settings file lists no Tables") } + // The tool home is resolved from the working directory, which must be the + // repo root — the same invariant gen_db already has (its ./tools/... and + // ./assets literals). Fail loud otherwise. + toolHome, err := filepath.Abs(filepath.Join("tools", "db2tool")) + if err != nil { + return err + } + if _, err := os.Stat(toolHome); err != nil { + return fmt.Errorf("tools/db2tool not found — run from the repository root (CWD-dependent like gen_db): %w", err) + } + dbdCacheDir := opts.dbdDir + if dbdCacheDir == "" { + dbdCacheDir = filepath.Join(toolHome, "DBDCache") + } + + var buildNumber uint32 + var openTable func(tableName string) (*wdc.Table, error) + + if opts.buildNumber != 0 { + // Offline (Phase A) mode: pre-extracted .db2 files. + buildNumber = opts.buildNumber + db2Dir := opts.db2Dir + if db2Dir == "" { + db2Dir = filepath.Join(toolHome, "dbfilesclient") + } + openTable = func(tableName string) (*wdc.Table, error) { + return wdc.ReadFile(filepath.Join(db2Dir, tableName+".db2")) + } + } else { + // Local-CASC mode (the default): everything from the install. + if settings.Settings.BaseDir == "" { + return fmt.Errorf("settings BaseDir is required (or pass --build for offline mode)") + } + build, err := tact.Open(settings.Settings.BaseDir, settings.Settings.Product) + if err != nil { + return err + } + buildNumber = build.BuildNumber + fmt.Printf("Extracting %s %s (build %d) from local install\n", build.Entry.Product, build.Entry.Version, buildNumber) + + listfile := &tact.Listfile{Path: filepath.Join(toolHome, "listfile.csv")} + if err := listfile.Refresh(); err != nil { + return err + } + + // GameTables: raw bytes, filename casing preserved from settings. + gameTablesOutDir := resolvePath(toolHome, settings.GameTablesOutDirectory) + if err := os.MkdirAll(gameTablesOutDir, 0o755); err != nil { + return err + } + for _, gameTable := range settings.GameTables { + fdid, err := listfile.GetFDID("gametables/" + gameTable + ".txt") + if err != nil { + return err + } + data, err := build.OpenFileByFDID(fdid) + if err != nil { + return fmt.Errorf("gametable %s: %w", gameTable, err) + } + if err := os.WriteFile(filepath.Join(gameTablesOutDir, gameTable+".txt"), data, 0o644); err != nil { + return err + } + } + + // Tables: extract each .db2 to the target directory, then parse it. + // The FDID key uses the RAW settings TargetDirectory value; only the + // on-disk output use is resolved (plan §7 M4 carve-out). + targetDirOnDisk := resolvePath(toolHome, settings.TargetDirectory) + if err := os.MkdirAll(targetDirOnDisk, 0o755); err != nil { + return err + } + openTable = func(tableName string) (*wdc.Table, error) { + fdid, err := listfile.GetFDID(settings.TargetDirectory + "/" + tableName + ".db2") + if err != nil { + return nil, err + } + data, err := build.OpenFileByFDID(fdid) + if err != nil { + return nil, fmt.Errorf("table %s: %w", tableName, err) + } + path := filepath.Join(targetDirOnDisk, tableName+".db2") + if err := os.WriteFile(path, data, 0o644); err != nil { + return nil, err + } + return wdc.ReadFile(path) + } + } + type loaded struct { def sqlite.TableDef decoded *wdc.Decoded @@ -106,19 +208,23 @@ func run(args []string) error { tableDefs := make([]sqlite.TableDef, 0, len(settings.Tables)) for _, tableName := range settings.Tables { - table, err := wdc.ReadFile(filepath.Join(opts.db2Dir, tableName+".db2")) + table, err := openTable(tableName) + if err != nil { + return err + } + dbdPath, err := dbd.FetchCached(dbdCacheDir, tableName) if err != nil { return err } - def, err := dbd.ReadFile(filepath.Join(opts.dbdDir, tableName+".dbd"), true) + def, err := dbd.ReadFile(dbdPath, true) if err != nil { return err } - version, err := dbd.SelectVersion(def, opts.buildNumber) + version, err := dbd.SelectVersion(def, buildNumber) if err != nil { return fmt.Errorf("table %s: %w", tableName, err) } - decoded, err := table.DecodeRows(def, version, opts.buildNumber) + decoded, err := table.DecodeRows(def, version, buildNumber) if err != nil { return fmt.Errorf("table %s: %w", tableName, err) } diff --git a/tools/db2tool/tact/blte.go b/tools/db2tool/tact/blte.go new file mode 100644 index 0000000000..d81e0c5bac --- /dev/null +++ b/tools/db2tool/tact/blte.go @@ -0,0 +1,103 @@ +// Go translation of TACTSharp's BLTE decoder (https://github.com/wowdev/TACTSharp, +// v0.0.13-alpha, commit d0ab516eb98b5db35682467b6e4977d88955046d). +// Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. +// +// Keyless port: 'E' (encrypted) chunks are left zero-filled in the output — +// exactly what the .NET tool produces without TACT keys, and what the WDC +// layer's encrypted-section skip expects (plan §7 C1/M5). 'F' never occurs. +package tact + +import ( + "bytes" + "compress/zlib" + "fmt" + "io" +) + +// blteDecode decodes a BLTE blob. totalDecompSize may be 0 (computed from +// chunk infos for multi-chunk files; required for single-chunk non-'N'). +func blteDecode(data []byte, totalDecompSize uint64) ([]byte, error) { + if len(data) < 8 || data[0] != 'B' || data[1] != 'L' || data[2] != 'T' || data[3] != 'E' { + return nil, fmt.Errorf("invalid BLTE header") + } + const fixedHeaderSize = 8 + headerSize := int(be32(data[4:])) + + if headerSize == 0 { + mode := data[fixedHeaderSize] + if mode != 'N' && totalDecompSize == 0 { + return nil, fmt.Errorf("totalDecompSize must be set for single non-normal BLTE block") + } + if mode == 'N' && totalDecompSize == 0 { + totalDecompSize = uint64(len(data) - fixedHeaderSize - 1) + } + out := make([]byte, totalDecompSize) + if err := handleDataBlock(mode, data[fixedHeaderSize+1:], out); err != nil { + return nil, err + } + return out, nil + } + + if data[fixedHeaderSize] != 0xF { + return nil, fmt.Errorf("unexpected BLTE table format 0x%x", data[fixedHeaderSize]) + } + const blockInfoSize = 24 + chunkCount := int(data[fixedHeaderSize+1])<<16 | int(data[fixedHeaderSize+2])<<8 | int(data[fixedHeaderSize+3]) + infoStart := fixedHeaderSize + 4 + + if totalDecompSize == 0 { + o := infoStart + 4 + for i := 0; i < chunkCount; i++ { + totalDecompSize += uint64(be32(data[o:])) + o += blockInfoSize + } + } + + out := make([]byte, totalDecompSize) + infoOffset := infoStart + compOffset := headerSize + decompOffset := 0 + + for chunk := 0; chunk < chunkCount; chunk++ { + compSize := int(be32(data[infoOffset:])) + decompSize := int(be32(data[infoOffset+4:])) + if compOffset+compSize > len(data) || decompOffset+decompSize > len(out) { + return nil, fmt.Errorf("BLTE chunk %d out of bounds", chunk) + } + if err := handleDataBlock(data[compOffset], data[compOffset+1:compOffset+compSize], out[decompOffset:decompOffset+decompSize]); err != nil { + return nil, fmt.Errorf("BLTE chunk %d: %w", chunk, err) + } + infoOffset += blockInfoSize + compOffset += compSize + decompOffset += decompSize + } + return out, nil +} + +func handleDataBlock(mode byte, compData, out []byte) error { + switch mode { + case 'N': + copy(out, compData) + return nil + case 'Z': + zr, err := zlib.NewReader(bytes.NewReader(compData)) + if err != nil { + return err + } + defer zr.Close() + _, err = io.ReadFull(zr, out) + return err + case 'E': + // Keyless: leave the output range zero-filled (upstream's TryDecrypt + // finds no key and writes nothing). + return nil + case 'F': + return fmt.Errorf("BLTE frame ('F') decompression not implemented (never occurs in this data)") + default: + return fmt.Errorf("invalid BLTE chunk mode %q", mode) + } +} + +func be32(b []byte) uint32 { + return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) +} diff --git a/tools/db2tool/tact/build.go b/tools/db2tool/tact/build.go new file mode 100644 index 0000000000..f8605194e3 --- /dev/null +++ b/tools/db2tool/tact/build.go @@ -0,0 +1,134 @@ +// Local-CASC build orchestration — the pure-local equivalent of TACTSharp's +// BuildInstance (https://github.com/wowdev/TACTSharp, v0.0.13-alpha, commit +// d0ab516eb98b5db35682467b6e4977d88955046d): FDID → root CKey → encoding EKey +// → local .idx → data.NNN → BLTE. No CDN, no group/file indices (upstream +// consults them but the local .idx always wins for resident files). +// Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. +package tact + +import ( + "encoding/hex" + "fmt" + "path/filepath" +) + +type Build struct { + Entry AvailableBuild + BuildNumber uint32 + BuildConfig map[string][]string + CDNConfig map[string][]string + + store *cascStore + encoding *encodingTable + root *rootTable +} + +// Open loads everything needed to serve OpenFileByFDID from a local install. +func Open(baseDir, product string) (*Build, error) { + entries, err := ParseBuildInfo(filepath.Join(baseDir, ".build.info")) + if err != nil { + return nil, err + } + entry, err := SelectBuild(entries, product) + if err != nil { + return nil, err + } + buildNumber, err := BuildNumber(entry.Version) + if err != nil { + return nil, err + } + + buildConfig, err := LoadConfig(baseDir, entry.BuildConfig) + if err != nil { + return nil, fmt.Errorf("loading build config: %w", err) + } + cdnConfig, err := LoadConfig(baseDir, entry.CDNConfig) + if err != nil { + return nil, fmt.Errorf("loading cdn config: %w", err) + } + + store, err := openCascStore(baseDir) + if err != nil { + return nil, fmt.Errorf("opening local CASC store: %w", err) + } + + b := &Build{ + Entry: entry, + BuildNumber: buildNumber, + BuildConfig: buildConfig, + CDNConfig: cdnConfig, + store: store, + } + + // Encoding: the build config's `encoding` line is `ckey ekey`; open by the + // EKey directly (BuildInstance.Load uses encoding[1]). + encodingKeys, ok := buildConfig["encoding"] + if !ok || len(encodingKeys) < 2 { + return nil, fmt.Errorf("no encoding key pair in build config") + } + encodingRaw, err := b.openEKeyHex(encodingKeys[1], 0) + if err != nil { + return nil, fmt.Errorf("opening encoding file: %w", err) + } + if b.encoding, err = parseEncoding(encodingRaw); err != nil { + return nil, err + } + + // Root: config gives the CKey; resolve via encoding. + rootKey, ok := buildConfig["root"] + if !ok || len(rootKey) < 1 { + return nil, fmt.Errorf("no root key in build config") + } + rootCKey, err := hex.DecodeString(rootKey[0]) + if err != nil { + return nil, fmt.Errorf("invalid root ckey: %w", err) + } + rootRaw, err := b.OpenFileByCKey(rootCKey) + if err != nil { + return nil, fmt.Errorf("opening root file: %w", err) + } + if b.root, err = parseRoot(rootRaw); err != nil { + return nil, err + } + + return b, nil +} + +func (b *Build) openEKeyHex(eKeyHex string, decodedSize uint64) ([]byte, error) { + eKey, err := hex.DecodeString(eKeyHex) + if err != nil { + return nil, fmt.Errorf("invalid ekey %q: %w", eKeyHex, err) + } + return b.openEKey(eKey, decodedSize) +} + +func (b *Build) openEKey(eKey []byte, decodedSize uint64) ([]byte, error) { + raw, err := b.store.readEKey(eKey) + if err != nil { + return nil, err + } + return blteDecode(raw, decodedSize) +} + +// OpenFileByCKey resolves a content key through encoding and opens the first +// encoding key locally. +func (b *Build) OpenFileByCKey(cKey []byte) ([]byte, error) { + eKey, decodedSize, ok := b.encoding.findContentKey(cKey) + if !ok { + return nil, fmt.Errorf("ckey %x not found in encoding", cKey) + } + return b.openEKey(eKey, decodedSize) +} + +// OpenFileByFDID opens a file by its file data id via the WoW root. +func (b *Build) OpenFileByFDID(fdid uint32) ([]byte, error) { + cKey, ok := b.root.byFDID[fdid] + if !ok { + return nil, fmt.Errorf("fdid %d not found in root", fdid) + } + data, err := b.OpenFileByCKey(cKey[:]) + if err != nil { + return nil, fmt.Errorf("fdid %d: %w", fdid, err) + } + return data, nil +} diff --git a/tools/db2tool/tact/buildinfo.go b/tools/db2tool/tact/buildinfo.go new file mode 100644 index 0000000000..b39874c82c --- /dev/null +++ b/tools/db2tool/tact/buildinfo.go @@ -0,0 +1,85 @@ +// Go translation of TACTSharp's BuildInfo (https://github.com/wowdev/TACTSharp, +// v0.0.13-alpha, commit d0ab516eb98b5db35682467b6e4977d88955046d). +// Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. +package tact + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +type AvailableBuild struct { + BuildConfig string + CDNConfig string + CDNPath string + Version string + Product string +} + +// ParseBuildInfo reads /.build.info (typed pipe format: the header +// line names columns as "Name!TYPE:len") and returns all product entries. +func ParseBuildInfo(path string) ([]AvailableBuild, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var entries []AvailableBuild + headerMap := map[string]int{} + for _, line := range strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n") { + if line == "" { + continue + } + split := strings.Split(line, "|") + if strings.HasPrefix(split[0], "Branch!") { + for i, header := range split { + headerMap[strings.Split(header, "!")[0]] = i + } + continue + } + col := func(name string) string { + idx, ok := headerMap[name] + if !ok || idx >= len(split) { + return "" + } + return split[idx] + } + entries = append(entries, AvailableBuild{ + BuildConfig: col("Build Key"), + CDNConfig: col("CDN Key"), + CDNPath: col("CDN Path"), + Version: col("Version"), + Product: col("Product"), + }) + } + if len(entries) == 0 { + return nil, fmt.Errorf("%s: no build entries found", path) + } + return entries, nil +} + +// SelectBuild returns the first entry for the given product (Program.cs uses +// Entries.First(x => x.Product == settings.Product)). +func SelectBuild(entries []AvailableBuild, product string) (AvailableBuild, error) { + for _, e := range entries { + if e.Product == product { + return e, nil + } + } + return AvailableBuild{}, fmt.Errorf("product %q not found in .build.info", product) +} + +// BuildNumber extracts the trailing build number from a 4-part version string +// (Program.cs: uint.Parse(Version.Split('.')[3])). +func BuildNumber(version string) (uint32, error) { + split := strings.Split(version, ".") + if len(split) != 4 { + return 0, fmt.Errorf("invalid build %q", version) + } + n, err := strconv.ParseUint(split[3], 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid build %q: %w", version, err) + } + return uint32(n), nil +} diff --git a/tools/db2tool/tact/cascidx.go b/tools/db2tool/tact/cascidx.go new file mode 100644 index 0000000000..09fc1d7001 --- /dev/null +++ b/tools/db2tool/tact/cascidx.go @@ -0,0 +1,175 @@ +// Go translation of TACTSharp's CASCIndexInstance + the local-archive read +// from CDN.TryGetLocalFile (https://github.com/wowdev/TACTSharp, +// v0.0.13-alpha, commit d0ab516eb98b5db35682467b6e4977d88955046d). +// Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. +package tact + +import ( + "bytes" + "encoding/binary" + "fmt" + "os" + "path/filepath" + "strings" +) + +// cascIndex is one parsed .idx bucket file (v7: 9-byte key prefixes, 5-byte +// packed archive/offset, 4-byte size). +type cascIndex struct { + entrySizeBytes int + entryOffsetBytes int + entryKeyBytes int + entries []byte // raw entry block + entrySize int +} + +const cascIdxHeaderSize = 40 + +func loadCascIndex(path string) (*cascIndex, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if len(raw) < cascIdxHeaderSize { + return nil, fmt.Errorf("%s: too small for .idx header", path) + } + // IndexHeader layout (C# sequential struct with natural alignment): + // u32 headerHashSize, u32 headerHash, u16 version, u8 bucketIndex, + // u8 extraBytes, u8 entrySizeBytes, u8 entryOffsetBytes, u8 entryKeyBytes, + // u8 entryOffsetBits, u64 maxArchiveSize @16, 8 pad, u32 entriesSize @32. + version := binary.LittleEndian.Uint16(raw[8:10]) + if version != 7 { + return nil, fmt.Errorf("%s: unsupported .idx version %d (want 7)", path, version) + } + idx := &cascIndex{ + entrySizeBytes: int(raw[12]), + entryOffsetBytes: int(raw[13]), + entryKeyBytes: int(raw[14]), + } + idx.entrySize = idx.entrySizeBytes + idx.entryOffsetBytes + idx.entryKeyBytes + entriesSize := int(binary.LittleEndian.Uint32(raw[32:36])) + if cascIdxHeaderSize+entriesSize > len(raw) { + return nil, fmt.Errorf("%s: entries block exceeds file size", path) + } + idx.entries = raw[cascIdxHeaderSize : cascIdxHeaderSize+entriesSize] + return idx, nil +} + +// getIndexInfo returns (archiveOffset, size, archiveIndex) for an eKey, with +// the 30-byte per-entry storage frame already skipped (offset+30, size-30), +// or (-1,-1,-1) when absent. +// +// Deviation from upstream, in the safe direction: TACTSharp reports a miss +// whenever lower_bound lands on entry 0 even if it matches; this port accepts +// a genuine entry-0 match (upstream silently falls back to the CDN there — +// this pure-local port has no fallback to hide behind). +func (idx *cascIndex) getIndexInfo(eKey []byte) (int64, int, int) { + needle := eKey[:idx.entryKeyBytes] + n := len(idx.entries) / idx.entrySize + lo, hi := 0, n + for lo < hi { + mid := (lo + hi) / 2 + key := idx.entries[mid*idx.entrySize : mid*idx.entrySize+idx.entryKeyBytes] + if bytes.Compare(key, needle) < 0 { + lo = mid + 1 + } else { + hi = mid + } + } + if lo >= n { + return -1, -1, -1 + } + entry := idx.entries[lo*idx.entrySize : (lo+1)*idx.entrySize] + if !bytes.Equal(entry[:idx.entryKeyBytes], needle) { + return -1, -1, -1 + } + k := idx.entryKeyBytes + indexHigh := int(entry[k]) + indexLow := int(binary.BigEndian.Uint32(entry[k+1 : k+5])) + size := int(binary.LittleEndian.Uint32(entry[k+5:k+5+idx.entrySizeBytes])) - 30 + archiveIndex := indexHigh<<2 | (indexLow&0xC0000000)>>30 + archiveOffset := int64(indexLow&0x3FFFFFFF) + 30 + return archiveOffset, size, archiveIndex +} + +// cascStore is the set of .idx buckets plus the data.NNN archive directory. +type cascStore struct { + dataDir string + buckets map[byte]*cascIndex +} + +// openCascStore loads the highest-version .idx per bucket from +// /Data/data (CDN.LoadCASCIndices). +func openCascStore(baseDir string) (*cascStore, error) { + dataDir := filepath.Join(baseDir, "Data", "data") + entries, err := os.ReadDir(dataDir) + if err != nil { + return nil, err + } + highest := map[byte]int64{} + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".idx") || strings.Contains(name, "tempfile") { + continue + } + stem := strings.TrimSuffix(name, ".idx") + if len(stem) != 10 { + continue + } + var bucket byte + if _, err := fmt.Sscanf(stem[0:2], "%02x", &bucket); err != nil { + continue + } + var version int64 + if _, err := fmt.Sscanf(stem[2:], "%08x", &version); err != nil { + continue + } + if v, ok := highest[bucket]; !ok || version > v { + highest[bucket] = version + } + } + if len(highest) == 0 { + return nil, fmt.Errorf("no .idx files found in %s", dataDir) + } + store := &cascStore{dataDir: dataDir, buckets: map[byte]*cascIndex{}} + for bucket, version := range highest { + path := filepath.Join(dataDir, fmt.Sprintf("%02x%08x.idx", bucket, version)) + idx, err := loadCascIndex(path) + if err != nil { + return nil, err + } + store.buckets[bucket] = idx + } + return store, nil +} + +// bucketForEKey ports CDN.TryGetLocalFile's bucket selection: XOR of the +// first 9 eKey bytes, then fold nibbles. +func bucketForEKey(eKey []byte) byte { + i := eKey[0] ^ eKey[1] ^ eKey[2] ^ eKey[3] ^ eKey[4] ^ eKey[5] ^ eKey[6] ^ eKey[7] ^ eKey[8] + return (i & 0xf) ^ (i >> 4) +} + +// readEKey returns the raw (BLTE-encoded) bytes for an eKey from the local +// archives, or an error when the key is not locally resident. +func (s *cascStore) readEKey(eKey []byte) ([]byte, error) { + idx, ok := s.buckets[bucketForEKey(eKey)] + if !ok { + return nil, fmt.Errorf("no .idx bucket %02x", bucketForEKey(eKey)) + } + offset, size, archiveIndex := idx.getIndexInfo(eKey) + if offset == -1 { + return nil, fmt.Errorf("eKey %x not found in local CASC indices", eKey) + } + archivePath := filepath.Join(s.dataDir, fmt.Sprintf("data.%03d", archiveIndex)) + f, err := os.Open(archivePath) + if err != nil { + return nil, err + } + defer f.Close() + buf := make([]byte, size) + if _, err := f.ReadAt(buf, offset); err != nil { + return nil, fmt.Errorf("reading %s @%d+%d: %w", archivePath, offset, size, err) + } + return buf, nil +} diff --git a/tools/db2tool/tact/config.go b/tools/db2tool/tact/config.go new file mode 100644 index 0000000000..25f62f37d2 --- /dev/null +++ b/tools/db2tool/tact/config.go @@ -0,0 +1,37 @@ +// Go translation of TACTSharp's Config (https://github.com/wowdev/TACTSharp, +// v0.0.13-alpha, commit d0ab516eb98b5db35682467b6e4977d88955046d). +// Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. +package tact + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// LoadConfig reads a build/CDN config from the local install's +// Data/config/// layout. Values are space-separated (typically +// `ckey [ekey]`). All keys are kept, including the ~318 unused `vfs-*` TVFS +// lines (plan §10 Q4) — they parse fine and are simply never consulted. +func LoadConfig(baseDir, hash string) (map[string][]string, error) { + if len(hash) != 32 { + return nil, fmt.Errorf("invalid config hash %q", hash) + } + path := filepath.Join(baseDir, "Data", "config", hash[0:2], hash[2:4], hash) + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if len(raw) == 0 || raw[0] != '#' { + return nil, fmt.Errorf("%s: config file is unreadable", path) + } + values := map[string][]string{} + for _, line := range strings.Split(string(raw), "\n") { + splitLine := strings.SplitN(line, "=", 2) + if len(splitLine) > 1 { + values[strings.TrimSpace(splitLine[0])] = strings.Split(strings.TrimSpace(splitLine[1]), " ") + } + } + return values, nil +} diff --git a/tools/db2tool/tact/encoding.go b/tools/db2tool/tact/encoding.go new file mode 100644 index 0000000000..f7b6bfd503 --- /dev/null +++ b/tools/db2tool/tact/encoding.go @@ -0,0 +1,89 @@ +// Go translation of TACTSharp's EncodingInstance (https://github.com/wowdev/TACTSharp, +// v0.0.13-alpha, commit d0ab516eb98b5db35682467b6e4977d88955046d). +// Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. +package tact + +import ( + "bytes" + "fmt" +) + +// encodingTable supports CKey→EKey resolution over the decoded encoding file +// (the "EN" table: paged, big-endian, 40-bit decoded sizes). +type encodingTable struct { + data []byte + ckeySize int + ekeySize int + pageSize int + pageCount int + headerOff int // ckey page-header block offset + pagesOff int // ckey pages block offset +} + +func parseEncoding(data []byte) (*encodingTable, error) { + if len(data) < 22 || data[0] != 'E' || data[1] != 'N' { + return nil, fmt.Errorf("invalid encoding file magic") + } + if data[2] != 1 { + return nil, fmt.Errorf("unsupported encoding version %d", data[2]) + } + e := &encodingTable{data: data} + e.ckeySize = int(data[3]) + e.ekeySize = int(data[4]) + ckeyPageSize := int(uint16(data[5])<<8|uint16(data[6])) * 1024 + ckeyPageCount := int(be32(data[9:])) + especBlockSize := int(be32(data[0x12:])) + + e.pageSize = ckeyPageSize + e.pageCount = ckeyPageCount + e.headerOff = 22 + especBlockSize + e.pagesOff = e.headerOff + ckeyPageCount*(e.ckeySize+0x10) + if e.pagesOff+ckeyPageCount*ckeyPageSize > len(data) { + return nil, fmt.Errorf("encoding ckey pages exceed file size") + } + return e, nil +} + +// findContentKey returns the first eKey and decoded file size for a cKey, or +// ok=false when absent. Page selection: last page header whose first key <= +// target; then a linear record scan within the page. +func (e *encodingTable) findContentKey(cKey []byte) (eKey []byte, decodedSize uint64, ok bool) { + entrySize := e.ckeySize + 0x10 + n := e.pageCount + // upper_bound on page first-keys, then step back one. + lo, hi := 0, n + for lo < hi { + mid := (lo + hi) / 2 + key := e.data[e.headerOff+mid*entrySize : e.headerOff+mid*entrySize+e.ckeySize] + if bytes.Compare(key, cKey) <= 0 { + lo = mid + 1 + } else { + hi = mid + } + } + pageIndex := lo - 1 + if pageIndex < 0 { + return nil, 0, false + } + + page := e.data[e.pagesOff+pageIndex*e.pageSize : e.pagesOff+(pageIndex+1)*e.pageSize] + for len(page) >= 1+5+e.ckeySize { + keyCount := int(page[0]) + recLen := 5 + e.ckeySize + e.ekeySize*keyCount + if 1+recLen > len(page) { + break + } + rec := page[1 : 1+recLen] + if keyCount == 0 { + page = page[1+recLen:] + continue + } + recCKey := rec[5 : 5+e.ckeySize] + if bytes.Equal(recCKey, cKey) { + size := uint64(rec[0])<<32 | uint64(rec[1])<<24 | uint64(rec[2])<<16 | uint64(rec[3])<<8 | uint64(rec[4]) + return rec[5+e.ckeySize : 5+e.ckeySize+e.ekeySize], size, true + } + page = page[1+recLen:] + } + return nil, 0, false +} diff --git a/tools/db2tool/tact/fdid.go b/tools/db2tool/tact/fdid.go new file mode 100644 index 0000000000..12bc69a3a3 --- /dev/null +++ b/tools/db2tool/tact/fdid.go @@ -0,0 +1,146 @@ +// FDID resolution: a static path→FDID map for the configured tables/gametables +// (primary; FDIDs are stable per path), with the community listfile.csv as the +// fallback for paths not in the map. Replaces TACTSharp's Jenkins96-hashed +// listfile lookup (https://github.com/wowdev/TACTSharp) with plain +// case-normalized paths. Original repo code (MIT). +package tact + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +// staticFDIDs was generated from the community listfile for the 80 paths the +// generator settings reference (72 dbfilesclient/*.db2 + 8 gametables/*.txt), +// build 5.5.4.68571 snapshot. Keys are lowercase game paths. +var staticFDIDs = map[string]uint32{ + "dbfilesclient/areatable.db2": 1353545, + "dbfilesclient/armorlocation.db2": 1284818, + "dbfilesclient/curve.db2": 892585, + "dbfilesclient/curvepoint.db2": 892586, + "dbfilesclient/difficulty.db2": 1352127, + "dbfilesclient/faction.db2": 1361972, + "dbfilesclient/gemproperties.db2": 1343604, + "dbfilesclient/glyphproperties.db2": 1345274, + "dbfilesclient/item.db2": 841626, + "dbfilesclient/itemarmorquality.db2": 1283021, + "dbfilesclient/itemarmorshield.db2": 1277741, + "dbfilesclient/itemarmortotal.db2": 1283022, + "dbfilesclient/itembonus.db2": 959070, + "dbfilesclient/itemclass.db2": 1140189, + "dbfilesclient/itemdamageammo.db2": 1277740, + "dbfilesclient/itemdamageonehand.db2": 1277743, + "dbfilesclient/itemdamageonehandcaster.db2": 1277739, + "dbfilesclient/itemdamageranged.db2": 6156256, + "dbfilesclient/itemdamagethrown.db2": 6156257, + "dbfilesclient/itemdamagetwohand.db2": 1277738, + "dbfilesclient/itemdamagetwohandcaster.db2": 1277742, + "dbfilesclient/itemdamagewand.db2": 6156258, + "dbfilesclient/itemeffect.db2": 969941, + "dbfilesclient/itemextendedcost.db2": 801681, + "dbfilesclient/itemnamedescription.db2": 1332559, + "dbfilesclient/itemrandomproperties.db2": 1237441, + "dbfilesclient/itemrandomsuffix.db2": 1237592, + "dbfilesclient/itemreforge.db2": 5633983, + "dbfilesclient/itemset.db2": 1343609, + "dbfilesclient/itemsetspell.db2": 1314689, + "dbfilesclient/itemsparse.db2": 1572924, + "dbfilesclient/itemsubclass.db2": 1261604, + "dbfilesclient/itemsubclassmask.db2": 1302852, + "dbfilesclient/itemupgrade.db2": 801687, + "dbfilesclient/journalencounter.db2": 1240336, + "dbfilesclient/journalencounteritem.db2": 1344467, + "dbfilesclient/journalinstance.db2": 1237438, + "dbfilesclient/map.db2": 1349477, + "dbfilesclient/randproppoints.db2": 1310245, + "dbfilesclient/rulesetitemupgrade.db2": 801749, + "dbfilesclient/scalingstatdistribution.db2": 1141728, + "dbfilesclient/skillline.db2": 1240935, + "dbfilesclient/skilllineability.db2": 1266278, + "dbfilesclient/spell.db2": 1140089, + "dbfilesclient/spellauraoptions.db2": 1139952, + "dbfilesclient/spellcategories.db2": 1139939, + "dbfilesclient/spellcategory.db2": 1280619, + "dbfilesclient/spellclassoptions.db2": 979663, + "dbfilesclient/spellcooldowns.db2": 1139924, + "dbfilesclient/spelldescriptionvariables.db2": 1140004, + "dbfilesclient/spellduration.db2": 1137828, + "dbfilesclient/spelleffect.db2": 1140088, + "dbfilesclient/spellequippeditems.db2": 1140011, + "dbfilesclient/spellinterrupts.db2": 1139906, + "dbfilesclient/spellitemenchantment.db2": 1362771, + "dbfilesclient/spelllabel.db2": 1347275, + "dbfilesclient/spelllevels.db2": 1140079, + "dbfilesclient/spellmechanic.db2": 1014438, + "dbfilesclient/spellmisc.db2": 1003144, + "dbfilesclient/spellname.db2": 1990283, + "dbfilesclient/spellpower.db2": 982806, + "dbfilesclient/spellprocsperminute.db2": 1133526, + "dbfilesclient/spellprocsperminutemod.db2": 1133525, + "dbfilesclient/spellradius.db2": 1134584, + "dbfilesclient/spellrange.db2": 1146820, + "dbfilesclient/spellreagents.db2": 841946, + "dbfilesclient/spellscaling.db2": 1139940, + "dbfilesclient/spellshapeshift.db2": 1139929, + "dbfilesclient/spelltargetrestrictions.db2": 1139993, + "dbfilesclient/spellxdescriptionvariables.db2": 1724949, + "dbfilesclient/talent.db2": 1369062, + "dbfilesclient/talenttab.db2": 2178102, + "gametables/chancetomeleecrit.txt": 3999262, + "gametables/chancetomeleecritbase.txt": 3999263, + "gametables/chancetospellcrit.txt": 3999265, + "gametables/chancetospellcritbase.txt": 3999264, + "gametables/combatratings.txt": 1391669, + "gametables/octbasehpbyclass.txt": 5464960, + "gametables/octbasempbyclass.txt": 4049853, + "gametables/spellscaling.txt": 1391660, +} + +// GetFDID resolves a game path (e.g. "dbfilesclient/Spell.db2") to its file +// data id: static map first, then the listfile (loaded lazily). The lookup +// key is the raw game path, lowercased — never a filesystem-resolved path +// (plan §7 M4 carve-out). +func (l *Listfile) GetFDID(path string) (uint32, error) { + key := strings.ToLower(path) + if fdid, ok := staticFDIDs[key]; ok { + return fdid, nil + } + if err := l.load(); err != nil { + return 0, fmt.Errorf("resolving %q: %w", path, err) + } + if fdid, ok := l.byPath[key]; ok { + return fdid, nil + } + return 0, fmt.Errorf("path %q not found in static FDID map or listfile", path) +} + +// load parses the listfile lazily (FDID;path per line). +func (l *Listfile) load() error { + if l.byPath != nil { + return nil + } + f, err := os.Open(l.Path) + if err != nil { + return err + } + defer f.Close() + l.byPath = make(map[string]uint32, 4<<20) + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 1<<20), 1<<20) + for scanner.Scan() { + line := scanner.Text() + sep := strings.IndexByte(line, ';') + if sep < 0 { + continue + } + fdid, err := strconv.ParseUint(line[:sep], 10, 32) + if err != nil { + continue + } + l.byPath[strings.ToLower(line[sep+1:])] = uint32(fdid) + } + return scanner.Err() +} diff --git a/tools/db2tool/tact/listfile.go b/tools/db2tool/tact/listfile.go new file mode 100644 index 0000000000..f3c15c510a --- /dev/null +++ b/tools/db2tool/tact/listfile.go @@ -0,0 +1,83 @@ +// Go translation of TACTSharp's Listfile download/freshness logic +// (https://github.com/wowdev/TACTSharp, v0.0.13-alpha, commit +// d0ab516eb98b5db35682467b6e4977d88955046d). +// Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. +package tact + +import ( + "fmt" + "io" + "net/http" + "os" + "time" +) + +const DefaultListfileURL = "https://github.com/wowdev/wow-listfile/releases/latest/download/community-listfile.csv" + +// Listfile manages the community listfile.csv: download-if-stale semantics +// matching upstream (HEAD + Last-Modified vs local mtime; on a failed +// freshness check upstream re-downloads, and on a failed download it falls +// back to the existing file when one exists). +type Listfile struct { + Path string + URL string + byPath map[string]uint32 +} + +// Refresh ensures the listfile exists and is current. It never deletes a +// usable existing file on network failure — the extractor (via the static +// FDID map) and gen_db's icon map can still run offline. +func (l *Listfile) Refresh() error { + url := l.URL + if url == "" { + url = DefaultListfileURL + } + info, statErr := os.Stat(l.Path) + if statErr == nil { + resp, err := http.Head(url) + if err == nil { + lastModified, perr := time.Parse(http.TimeFormat, resp.Header.Get("Last-Modified")) + resp.Body.Close() + if perr == nil && !lastModified.After(info.ModTime().UTC()) { + return nil // up to date + } + } + // Stale or check failed: attempt a re-download, but keep the existing + // file if that fails. + if err := l.download(url); err != nil { + fmt.Fprintf(os.Stderr, "db2tool: listfile refresh failed (%v), using existing %s\n", err, l.Path) + } + return nil + } + // No local file: the download must succeed. + if err := l.download(url); err != nil { + return fmt.Errorf("downloading listfile: %w", err) + } + return nil +} + +func (l *Listfile) download(url string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %s", resp.Status) + } + tmp := l.Path + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return err + } + if _, err := io.Copy(f, resp.Body); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, l.Path) +} diff --git a/tools/db2tool/tact/root.go b/tools/db2tool/tact/root.go new file mode 100644 index 0000000000..78110b8d08 --- /dev/null +++ b/tools/db2tool/tact/root.go @@ -0,0 +1,112 @@ +// Go translation of TACTSharp's RootInstance (https://github.com/wowdev/TACTSharp, +// v0.0.13-alpha, commit d0ab516eb98b5db35682467b6e4977d88955046d) — Normal +// load mode, enUS locale, FDID→CKey only. +// Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. +package tact + +import ( + "encoding/binary" + "fmt" +) + +const ( + localeEnUS = 0x2 + localeAllWoW = 0x2 | 0x4 | 0x10 | 0x20 | 0x40 | 0x80 | 0x100 | 0x200 | 0x1000 | 0x2000 | 0x4000 | 0x8000 | 0x10000 + contentLowViolence = 0x80 + contentNoNames = 0x10000000 + tsfmMagic = 1296454484 // "TSFM" +) + +// rootTable maps FDID → CKey (first entry wins, like entriesFDID.TryAdd). +type rootTable struct { + byFDID map[uint32][16]byte +} + +func parseRoot(data []byte) (*rootTable, error) { + r := &rootTable{byFDID: map[uint32][16]byte{}} + if len(data) < 12 { + return nil, fmt.Errorf("root file too small") + } + + newRoot := false + dfVersion := uint32(0) + offset := 0 + + if binary.LittleEndian.Uint32(data) == tsfmMagic { + newRoot = true + offset = 12 + totalFiles := binary.LittleEndian.Uint32(data[4:]) + namedFiles := binary.LittleEndian.Uint32(data[8:]) + if namedFiles == 1 || namedFiles == 2 { + // Post-10.1.7 header: [magic, headerSize, dfVersion, ...] + dfHeaderSize := totalFiles + dfVersion = namedFiles + offset = int(dfHeaderSize) + } + _ = totalFiles + } + + for offset < len(data) { + if offset+4 > len(data) { + return nil, fmt.Errorf("truncated root block header at %d", offset) + } + count := int(binary.LittleEndian.Uint32(data[offset:])) + offset += 4 + + var contentFlags, localeFlags uint32 + if dfVersion == 2 { + localeFlags = binary.LittleEndian.Uint32(data[offset:]) + unkFlags := binary.LittleEndian.Uint32(data[offset+4:]) + unkFlags2 := binary.LittleEndian.Uint32(data[offset+8:]) + unkByte := uint32(data[offset+12]) + offset += 13 + contentFlags = unkFlags | unkFlags2 | unkByte<<17 + } else { + contentFlags = binary.LittleEndian.Uint32(data[offset:]) + localeFlags = binary.LittleEndian.Uint32(data[offset+4:]) + offset += 8 + } + + localeSkip := localeFlags&localeAllWoW != localeAllWoW && localeFlags&localeEnUS == 0 + contentSkip := contentFlags&contentLowViolence != 0 + skipChunk := localeSkip || contentSkip + + separateLookup := newRoot + doLookup := !newRoot || contentFlags&contentNoNames == 0 + const sizeFdid, sizeCHash, sizeLookup = 4, 16, 8 + strideCHash := sizeCHash + sizeLookup + if separateLookup { + strideCHash = sizeCHash + } + offsetFdid := offset + offsetCHash := offsetFdid + count*sizeFdid + blockSize := count * (sizeFdid + sizeCHash) + if doLookup { + blockSize += count * sizeLookup + } + if offset+blockSize > len(data) { + return nil, fmt.Errorf("truncated root block at %d (need %d bytes)", offset, blockSize) + } + + if !skipChunk { + fileDataIndex := uint32(0) + for i := 0; i < count; i++ { + fdidOffset := binary.LittleEndian.Uint32(data[offsetFdid:]) + offsetFdid += sizeFdid + fdid := fileDataIndex + fdidOffset + fileDataIndex = fdid + 1 + + var md5 [16]byte + copy(md5[:], data[offsetCHash:offsetCHash+sizeCHash]) + offsetCHash += strideCHash + + if _, exists := r.byFDID[fdid]; !exists { + r.byFDID[fdid] = md5 + } + } + } + + offset += blockSize + } + return r, nil +} From 00f3c5b96ccf3d327f59a1dea9be0ca9ab4d7a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Hillerstr=C3=B6m?= Date: Thu, 16 Jul 2026 17:16:47 +0200 Subject: [PATCH 4/8] db2tool: DBCache.bin hotfix overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports DBCD.IO's HotfixReader/HTFXReader (XFTH v9) plus wow.tools.local's HotfixManager cache scanning and SStrHash to wdc/hotfix.go. Local-CASC runs now scan tools/db2tool/caches/*.bin and /**/DBCache.bin and apply the exact-build cache's records before insertion: stable ascending-PushId order, add/replace whole rows decoded from the byte-aligned blobs (DBD field metadata, non-inline id from RecordId, non-inline relations read at their DBD-declared MetaDataFieldType), deletes remove rows, Combine dedup on the full record identity including data bytes. --dbcache pins specific caches for deterministic runs; --no-hotfixes disables the overlay; the offline --build mode stays hotfix-free by default. Gates (build 68571, refs/DBCache.68571.bin snapshot): - with hotfixes vs refs/wowsims.hotfix.db: 2 diff lines total across all 72 tables — the documented CurvePoint Id=236585 float-notation divergence - --no-hotfixes vs refs/wowsims.nohotfix.db: same 2 lines only - overlay delta: +Item/ItemSparse 272920, +Spell/SpellName 1291315, -Item 277947, Spell 1298412 AuraDescription_lang modified - go build/vet/gofmt clean; TestHotfixGoldenParity added (skips without refs/), existing golden/wdc/dbd/sqlite tests pass; SStrHash(upper(name)) verified == WDC5 header TableHash for all 72 tables --- tools/db2tool/NOTICES.md | 3 +- tools/db2tool/hotfix_golden_test.go | 165 +++++++++++ tools/db2tool/main.go | 56 +++- tools/db2tool/wdc/hotfix.go | 409 ++++++++++++++++++++++++++++ tools/db2tool/wdc/row.go | 12 + 5 files changed, 636 insertions(+), 9 deletions(-) create mode 100644 tools/db2tool/hotfix_golden_test.go create mode 100644 tools/db2tool/wdc/hotfix.go diff --git a/tools/db2tool/NOTICES.md b/tools/db2tool/NOTICES.md index e49b23af64..989f39e9f4 100644 --- a/tools/db2tool/NOTICES.md +++ b/tools/db2tool/NOTICES.md @@ -10,6 +10,7 @@ the authoritative list of upstreams, licenses, and pinned revisions. | `wdc/` | [wowdev/DBCD](https://github.com/wowdev/DBCD) (DBCD + DBCD.IO, v2.1.2 — the version vendored as DLLs in `tools/DB2ToSqlite/references/`) | MIT, Copyright (c) 2020 wowdev | `2180edb4d08b3822b3cfa964293ba8ccd4236ac0` | | `dbd/` | [wowdev/WoWDBDefs](https://github.com/wowdev/WoWDBDefs) `code/C#/DBDefsLib` (**code** is BSD-3-Clause; the `.dbd` **data** files are CC BY-SA 4.0 and are fetched at build time, never vendored) | BSD-3-Clause, Copyright 2022 WoWDBDefs Contributors | `9002c532853a96d631c76dda50cb20189c27a173` (master at port time; the vendored DBDefsLib.dll is v1.0.0 with no embedded commit) | | `tact/` | [wowdev/TACTSharp](https://github.com/wowdev/TACTSharp) v0.0.13-alpha | MIT, Copyright (c) 2024 Martin Benjamins | `d0ab516eb98b5db35682467b6e4977d88955046d` | +| `wdc/hotfix.go` (cache scanning + SStrHash; the XFTH reader itself derives from DBCD above) | [Marlamin/wow.tools.local](https://github.com/Marlamin/wow.tools.local) `Services/{HotfixManager,DBCacheParser}.cs`, ported via this repo's committed copies at `tools/DB2ToSqlite/Helpers/` | MIT, Copyright (c) 2022 Martin Benjamins | `0aefbece74ef4e19ce67ebe91b51a8ae424c5c11` (upstream main at port time; the in-repo copies are the direct source) | | `sqlite/`, `config/`, `main.go` | original repo code (ports of this repo's own `tools/DB2ToSqlite/Helpers/*.cs` and `Program.cs`) | repo MIT | — | Runtime data dependencies (fetched, never vendored — see §4 of @@ -20,7 +21,7 @@ Runtime data dependencies (fetched, never vendored — see §4 of - `listfile.csv` (community listfile) — cached, gitignored. - No TACT keys are used; encrypted DB2 sections are skipped (plan §7 C1). -## MIT License (wowdev/DBCD, wowdev/TACTSharp) +## MIT License (wowdev/DBCD, wowdev/TACTSharp, Marlamin/wow.tools.local) MIT License diff --git a/tools/db2tool/hotfix_golden_test.go b/tools/db2tool/hotfix_golden_test.go new file mode 100644 index 0000000000..a3f7a6bf88 --- /dev/null +++ b/tools/db2tool/hotfix_golden_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/wowsims/mop/tools/db2tool/config" + "github.com/wowsims/mop/tools/db2tool/dbd" + "github.com/wowsims/mop/tools/db2tool/internal/golden" + "github.com/wowsims/mop/tools/db2tool/sqlite" + "github.com/wowsims/mop/tools/db2tool/wdc" + _ "modernc.org/sqlite" +) + +// Phase D golden gate (plan §6/§8): builds a wowsims.db from the +// pre-extracted snapshot WITH the refs/DBCache.68571.bin hotfix overlay +// applied, and diffs it against refs/wowsims.hotfix.db — a .NET-tool capture +// produced with that same cache. Row parity is strict for every table; the +// only tolerated divergence is the documented CurvePoint Id=236585 float +// notation (plan §5.5: .NET "[1,-6E-05]" vs Go "[1,-0.00006]") — exactly one +// line per side. +// +// Skips when the gitignored snapshot/refs assets are absent (e.g. CI). +func TestHotfixGoldenParity(t *testing.T) { + const snapshotBuild = 68571 + db2Dir := "../DB2ToSqlite/dbfilesclient" + dbdDir := "../DB2ToSqlite/DBDCache" + settingsPath := "../database/generator-settings.json" + cachePath := "refs/DBCache.68571.bin" + refPath := "refs/wowsims.hotfix.db" + + for _, p := range []string{db2Dir, dbdDir, settingsPath, cachePath, refPath} { + if _, err := os.Stat(p); os.IsNotExist(err) { + t.Skipf("%s not present; skipping hotfix golden gate", p) + } + } + + settings, err := config.Load(settingsPath) + if err != nil { + t.Fatal(err) + } + + readers, err := wdc.CombineHotfixFiles([]string{cachePath}) + if err != nil { + t.Fatal(err) + } + reader := readers[snapshotBuild] + if reader == nil { + t.Fatalf("%s holds no build-%d hotfixes", cachePath, snapshotBuild) + } + + outPath := filepath.Join(t.TempDir(), "wowsims.go.db") + goDB, err := sqlite.Open(outPath) + if err != nil { + t.Fatal(err) + } + defer goDB.Close() + + var tableDefs []sqlite.TableDef + decodedByTable := map[string]*wdc.Decoded{} + + for _, tableName := range settings.Tables { + table, err := wdc.ReadFile(filepath.Join(db2Dir, tableName+".db2")) + if err != nil { + t.Fatal(err) + } + // SStrHash port check: the uppercased table name must hash to the + // WDC5 header TableHash the hotfix records are keyed by. + if got := wdc.SStrHash(strings.ToUpper(tableName)); got != table.TableHash { + t.Errorf("SStrHash(%q) = 0x%08X, want header TableHash 0x%08X", tableName, got, table.TableHash) + } + def, err := dbd.ReadFile(filepath.Join(dbdDir, tableName+".dbd"), true) + if err != nil { + t.Fatal(err) + } + version, err := dbd.SelectVersion(def, snapshotBuild) + if err != nil { + t.Fatalf("%s: %v", tableName, err) + } + decoded, err := table.DecodeRows(def, version, snapshotBuild) + if err != nil { + t.Fatalf("%s: %v", tableName, err) + } + if err := reader.ApplyHotfixes(table, def, version, snapshotBuild, decoded); err != nil { + t.Fatalf("%s: applying hotfixes: %v", tableName, err) + } + tableDefs = append(tableDefs, sqlite.TableDef{Name: tableName, Def: def, Version: version}) + decodedByTable[tableName] = decoded + } + + if err := sqlite.CreateTables(goDB, tableDefs); err != nil { + t.Fatal(err) + } + for _, td := range tableDefs { + if err := sqlite.InsertRows(goDB, td, decodedByTable[td.Name]); err != nil { + t.Fatal(err) + } + } + + refDB, err := sql.Open("sqlite", refPath) + if err != nil { + t.Fatal(err) + } + defer refDB.Close() + + // Schema parity — hotfixes must never change schema. + refSchema, err := golden.SchemaDDL(refDB) + if err != nil { + t.Fatal(err) + } + goSchema, err := golden.SchemaDDL(goDB) + if err != nil { + t.Fatal(err) + } + if len(refSchema) != len(goSchema) { + t.Fatalf("schema object count: ref %d vs go %d", len(refSchema), len(goSchema)) + } + for i := range refSchema { + if refSchema[i] != goSchema[i] { + t.Errorf("schema mismatch:\n ref: %s\n go: %s", refSchema[i], goSchema[i]) + } + } + + // Row parity — strict, modulo the known CurvePoint notation divergence. + totalDiff := 0 + for _, td := range tableDefs { + refRows, err := golden.DumpRows(refDB, td.Name) + if err != nil { + t.Fatalf("ref %s: %v", td.Name, err) + } + goRows, err := golden.DumpRows(goDB, td.Name) + if err != nil { + t.Fatalf("go %s: %v", td.Name, err) + } + refOnly, goOnly := golden.DiffLines(refRows, goRows) + n := len(refOnly) + len(goOnly) + totalDiff += n + if n == 0 { + continue + } + if td.Name == "CurvePoint" && len(refOnly) == 1 && len(goOnly) == 1 && + strings.Contains(refOnly[0], "|236585|") && strings.Contains(refOnly[0], "-6E-05") && + strings.Contains(goOnly[0], "|236585|") && strings.Contains(goOnly[0], "-0.00006") { + t.Logf("CurvePoint: known Id=236585 float-notation divergence (2 lines, plan §5.5)") + continue + } + for i, l := range refOnly { + if i >= 3 { + break + } + t.Errorf("%s: ref-only row: %.200s", td.Name, l) + } + for i, l := range goOnly { + if i >= 3 { + break + } + t.Errorf("%s: go-only row: %.200s", td.Name, l) + } + t.Errorf("%s: %d row diff lines", td.Name, n) + } + t.Logf("total row diff lines across all tables: %d (2 expected: CurvePoint notation)", totalDiff) +} diff --git a/tools/db2tool/main.go b/tools/db2tool/main.go index 3e14b77ef0..a42221e4bf 100644 --- a/tools/db2tool/main.go +++ b/tools/db2tool/main.go @@ -4,10 +4,14 @@ // Default (Phase B) mode reads the local install named by the settings' // BaseDir: .build.info picks the build, files come from local CASC // (root → encoding → .idx → data.NNN → BLTE), .dbd definitions and the -// community listfile are fetched/cached over plain HTTPS. +// community listfile are fetched/cached over plain HTTPS. The client's +// DBCache.bin hotfixes for the extracted build are applied to the decoded +// rows (Phase D); --dbcache pins specific cache files instead of the +// default scan and --no-hotfixes disables the overlay. // // With --build (and optionally --db2dir/--dbddir), the offline Phase A mode -// decodes pre-extracted .db2 files instead — no install required. +// decodes pre-extracted .db2 files instead — no install required and no +// hotfixes unless --dbcache is given. package main import ( @@ -33,13 +37,16 @@ func main() { type options struct { settingsFile string databaseFile string - db2Dir string // offline mode only - dbdDir string // offline mode override - buildNumber uint32 // nonzero → offline mode + db2Dir string // offline mode only + dbdDir string // offline mode override + buildNumber uint32 // nonzero → offline mode + dbCaches []string // explicit DBCache files, overriding the default scan + noHotfixes bool // skip hotfix application entirely } // parseArgs mirrors Program.cs's pairwise scan, including the flag aliases -// (--settings/-s, --output/-output/-o), plus the offline-mode flags. +// (--settings/-s, --output/-output/-o), plus the offline-mode and hotfix +// flags. func parseArgs(args []string) (options, error) { opts := options{ settingsFile: "appsettings.json", @@ -63,6 +70,13 @@ func parseArgs(args []string) (options, error) { opts.db2Dir, err = next() case "--dbddir": opts.dbdDir, err = next() + case "--dbcache": + var f string + if f, err = next(); err == nil { + opts.dbCaches = append(opts.dbCaches, f) + } + case "--no-hotfixes": + opts.noHotfixes = true case "--build": var v string if v, err = next(); err == nil { @@ -202,6 +216,7 @@ func run(args []string) error { type loaded struct { def sqlite.TableDef + table *wdc.Table decoded *wdc.Decoded } tables := make([]loaded, 0, len(settings.Tables)) @@ -229,7 +244,7 @@ func run(args []string) error { return fmt.Errorf("table %s: %w", tableName, err) } td := sqlite.TableDef{Name: tableName, Def: def, Version: version} - tables = append(tables, loaded{def: td, decoded: decoded}) + tables = append(tables, loaded{def: td, table: table, decoded: decoded}) tableDefs = append(tableDefs, td) } @@ -243,9 +258,34 @@ func run(args []string) error { return err } - // Hotfixes (Phase D) would be applied here, before the inserts. + // Hotfixes (Phase D): overlay the client's DBCache.bin records before the + // inserts, mirroring Program.cs steps 10–11. Only a cache for this exact + // build applies; having none is not an error (Program.cs:102's throw is + // commented out). --dbcache pins specific cache files (deterministic + // runs); with no override, local-CASC mode scans tools/db2tool/caches + // plus /**/DBCache.bin like HotfixManager.LoadCaches, while the + // offline --build mode stays hotfix-free. + var hotfixReader *wdc.HotfixReader + if !opts.noHotfixes { + var readers map[uint32]*wdc.HotfixReader + if len(opts.dbCaches) > 0 { + if readers, err = wdc.CombineHotfixFiles(opts.dbCaches); err != nil { + return err + } + } else if opts.buildNumber == 0 { + if readers, err = wdc.LoadHotfixCaches(filepath.Join(toolHome, "caches"), settings.Settings.BaseDir); err != nil { + return err + } + } + hotfixReader = readers[buildNumber] + } for _, t := range tables { + if hotfixReader != nil { + if err := hotfixReader.ApplyHotfixes(t.table, t.def.Def, t.def.Version, buildNumber, t.decoded); err != nil { + return fmt.Errorf("table %s: applying hotfixes: %w", t.def.Name, err) + } + } if err := sqlite.InsertRows(db, t.def, t.decoded); err != nil { return err } diff --git a/tools/db2tool/wdc/hotfix.go b/tools/db2tool/wdc/hotfix.go new file mode 100644 index 0000000000..27a28391aa --- /dev/null +++ b/tools/db2tool/wdc/hotfix.go @@ -0,0 +1,409 @@ +// Go translation of DBCD.IO's hotfix support — HotfixReader, HTFXReader and +// HotfixEntryV9 (https://github.com/wowdev/DBCD, v2.1.2, commit +// 2180edb4d08b3822b3cfa964293ba8ccd4236ac0) — plus the DBCache scanning and +// SStrHash table-name hash ported from wow.tools.local's HotfixManager / +// DBCacheParser (https://github.com/Marlamin/wow.tools.local, via this repo's +// tools/DB2ToSqlite/Helpers copies). +// Copyright (c) 2020 wowdev; Copyright (c) 2022 Martin Benjamins. +// MIT License — see tools/db2tool/NOTICES.md. +package wdc + +import ( + "encoding/binary" + "fmt" + "io/fs" + "math" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/wowsims/mop/tools/db2tool/dbd" +) + +const hotfixMagic = "XFTH" + +// HotfixEntry is one XFTH v9 record: the HotfixEntryV9 header plus its data +// blob. DataSize always equals len(Data) after parsing; it is kept explicit +// because it is part of the Combine dedup identity. +type HotfixEntry struct { + RegionID int32 + PushID int32 + UniqueID int32 + TableHash uint32 + RecordID int32 + DataSize int32 + IsValid bool // status/op byte == 1 + Data []byte +} + +// hotfixIdentity is the effective HashSet identity Combine dedups +// on. HTFXRow.Equals compares the 5-tuple, but GetHashCode also hashes the +// record's data bytes (BitReader.GetHashCode), so records that differ only +// in data land in different buckets and are both kept — full-record identity, +// not the 5-tuple alone (plan §6 Phase D). RegionID/UniqueID never +// participate. +type hotfixIdentity struct { + pushID int32 + tableHash uint32 + recordID int32 + isValid bool + dataSize int32 + data string +} + +func (e *HotfixEntry) identity() hotfixIdentity { + return hotfixIdentity{ + pushID: e.PushID, + tableHash: e.TableHash, + recordID: e.RecordID, + isValid: e.IsValid, + dataSize: e.DataSize, + data: string(e.Data), + } +} + +// HotfixReader holds every hotfix record of one DBCache build, in +// file/combine insertion order. +type HotfixReader struct { + Version int32 + BuildID int32 + records []HotfixEntry +} + +// ReadHotfixFile parses one DBCache-format file. Only XFTH version 9 (the +// current live-client format) is supported; older versions fail loud (the C# +// HTFXReader also handles v1–v8 for long-obsolete clients — deliberately not +// ported, plan's minimal-surface rule). +func ReadHotfixFile(path string) (*HotfixReader, error) { + buf, err := os.ReadFile(path) + if err != nil { + return nil, err + } + h, err := parseHotfix(buf) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + return h, nil +} + +func parseHotfix(buf []byte) (*HotfixReader, error) { + if len(buf) < 12 { + return nil, fmt.Errorf("hotfix file is corrupted (shorter than the 12-byte header)") + } + if string(buf[0:4]) != hotfixMagic { + return nil, fmt.Errorf("hotfix file is corrupted (bad magic %q)", string(buf[0:4])) + } + h := &HotfixReader{ + Version: int32(binary.LittleEndian.Uint32(buf[4:8])), + BuildID: int32(binary.LittleEndian.Uint32(buf[8:12])), + } + if h.Version != 9 { + return nil, fmt.Errorf("unsupported DBCache version %d (only XFTH v9 is supported)", h.Version) + } + // Version >= 5 extended header: a 32-byte SHA hash, skipped. + if len(buf) < 44 { + return nil, fmt.Errorf("hotfix file is corrupted (shorter than the 44-byte extended header)") + } + pos := 44 + + for pos < len(buf) { + if pos+4 > len(buf) || string(buf[pos:pos+4]) != hotfixMagic { + return nil, fmt.Errorf("hotfix file is corrupted (bad entry magic at offset %d)", pos) + } + pos += 4 + if pos+28 > len(buf) { + return nil, fmt.Errorf("hotfix file is corrupted (truncated entry header at offset %d)", pos) + } + e := HotfixEntry{ + RegionID: int32(binary.LittleEndian.Uint32(buf[pos:])), + PushID: int32(binary.LittleEndian.Uint32(buf[pos+4:])), + UniqueID: int32(binary.LittleEndian.Uint32(buf[pos+8:])), + TableHash: binary.LittleEndian.Uint32(buf[pos+12:]), + RecordID: int32(binary.LittleEndian.Uint32(buf[pos+16:])), + DataSize: int32(binary.LittleEndian.Uint32(buf[pos+20:])), + IsValid: buf[pos+24] == 1, + // buf[pos+25:pos+28] is padding. + } + pos += 28 + if e.DataSize < 0 || pos+int(e.DataSize) > len(buf) { + return nil, fmt.Errorf("hotfix file is corrupted (truncated entry data at offset %d, size %d)", pos, e.DataSize) + } + e.Data = buf[pos : pos+int(e.DataSize) : pos+int(e.DataSize)] + pos += int(e.DataSize) + h.records = append(h.records, e) + } + return h, nil +} + +// Combine ports HTFXReader.Combine (+ HotfixReader.CombineCache's build +// check): other's records are appended in order unless an identical record +// is already present. Readers for a different build are ignored. +func (h *HotfixReader) Combine(other *HotfixReader) { + if other.BuildID != h.BuildID { + return + } + lookup := make(map[hotfixIdentity]struct{}, len(h.records)) + for i := range h.records { + lookup[h.records[i].identity()] = struct{}{} + } + for i := range other.records { + id := other.records[i].identity() + if _, ok := lookup[id]; !ok { + h.records = append(h.records, other.records[i]) + lookup[id] = struct{}{} + } + } +} + +// CombineHotfixFiles parses each file in order into readers keyed by BuildId: +// the first file seen for a build becomes its base reader and later files +// Combine into it — HotfixManager.LoadCaches's per-file loop. (C# also +// re-Combines the base file into itself, a no-op under the dedup.) +func CombineHotfixFiles(files []string) (map[uint32]*HotfixReader, error) { + readers := make(map[uint32]*HotfixReader) + for _, f := range files { + r, err := ReadHotfixFile(f) + if err != nil { + return nil, err + } + if base, ok := readers[uint32(r.BuildID)]; ok { + base.Combine(r) + } else { + readers[uint32(r.BuildID)] = r + } + fmt.Printf("Loaded hotfixes from %s for build %d\n", f, r.BuildID) + } + return readers, nil +} + +// LoadHotfixCaches ports HotfixManager.LoadCaches's scan: if cachesDir +// exists, every *.bin under it (recursively) loads first, then every file +// named DBCache.bin anywhere under baseDir. Finding no cache file is not an +// error (the no-hotfix throw in Program.cs:102 is commented out); a malformed +// or unsupported-version file fails loud. Files are visited in WalkDir's +// deterministic lexical order (.NET's enumeration order is unspecified). +func LoadHotfixCaches(cachesDir, baseDir string) (map[uint32]*HotfixReader, error) { + var files []string + if st, err := os.Stat(cachesDir); err == nil && st.IsDir() { + err := filepath.WalkDir(cachesDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && strings.HasSuffix(d.Name(), ".bin") { + files = append(files, path) + } + return nil + }) + if err != nil { + return nil, err + } + } + err := filepath.WalkDir(baseDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && d.Name() == "DBCache.bin" { + files = append(files, path) + } + return nil + }) + if err != nil { + return nil, err + } + return CombineHotfixFiles(files) +} + +// SStrHash ports HotfixManager.Hash — the Blizzard SStrHash variant DBCache +// table hashes use. Callers hash the UPPERCASED table name; the result equals +// the table's WDC5 header TableHash (which ApplyHotfixes actually keys on, +// like C# parser.TableHash). +func SStrHash(s string) uint32 { + sHashtable := [16]uint32{ + 0x486E26EE, 0xDCAA16B3, 0xE1918EEF, 0x202DAFDB, + 0x341C7DC7, 0x1C365303, 0x40EF2D37, 0x65FD5E49, + 0xD6057177, 0x904ECE93, 0x1C38024F, 0x98FD323B, + 0xE3061AE7, 0xA39B0FA1, 0x9797F25F, 0xE4444563, + } + v := uint32(0x7fed7fed) + x := uint32(0xeeeeeeee) + for i := 0; i < len(s); i++ { + c := uint32(s[i]) + v += x + v ^= sHashtable[(c>>4)&0xf] - sHashtable[c&0xf] + x = x*33 + v + c + 3 + } + return v +} + +// ApplyHotfixes ports HotfixReader.ReadHotfixes (with DefaultProcessor) + +// DBCDStorage.ApplyingHotfixes: this reader's records for table t overlay +// decoded in place. Records apply in a stable ascending-PushId sort +// (file/combine insertion order preserved within a PushId). An Add +// (IsValid && DataSize > 0) replaces or inserts the whole row decoded from +// the blob; otherwise the row is deleted when shouldDelete. Rows come back +// out in ascending-ID order, keeping the Phase A/B insertion contract. +func (h *HotfixReader) ApplyHotfixes(t *Table, def dbd.DBDefinition, version dbd.VersionDefinitions, buildNumber uint32, decoded *Decoded) error { + var recs []*HotfixEntry + for i := range h.records { + if h.records[i].TableHash == t.TableHash { + recs = append(recs, &h.records[i]) + } + } + if len(recs) == 0 { + return nil + } + + plans, err := buildFieldPlans(def, version, buildNumber) + if err != nil { + return err + } + + sort.SliceStable(recs, func(i, j int) bool { return recs[i].PushID < recs[j].PushID }) + + // The shouldDelete carve-out only affects TactKey (0xDF2F53CF) and + // BroadcastText (0x021826BB), neither of which is in Tables[] — ported + // faithfully anyway (plan §6 Phase D). + anyValidCached := false + for _, r := range recs { + if r.IsValid && r.PushID == -1 && r.DataSize > 0 { + anyValidCached = true + break + } + } + shouldDelete := (t.TableHash != 0xDF2F53CF && t.TableHash != 0x021826BB) || !anyValidCached + + byID := make(map[int32][]any, len(decoded.Rows)) + for _, row := range decoded.Rows { + byID[row.ID] = row.Values + } + + for _, rec := range recs { + switch { + case rec.IsValid && rec.DataSize > 0: // RowOp.Add + values, err := decodeHotfixRow(t, plans, rec) + if err != nil { + return fmt.Errorf("hotfix record %d (push %d): %w", rec.RecordID, rec.PushID, err) + } + byID[rec.RecordID] = values + case shouldDelete: // RowOp.Delete + delete(byID, rec.RecordID) + } + // else RowOp.Ignore + } + + ids := make([]int32, 0, len(byID)) + for id := range byID { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + + rows := make([]Row, len(ids)) + for i, id := range ids { + rows[i] = Row{ID: id, Values: byID[id]} + } + decoded.Rows = rows + return nil +} + +// decodeHotfixRow ports HTFXRow.GetFields. Hotfix blobs are NOT bitpacked: +// fields are byte-aligned little-endian values in definition order, at their +// DBD-declared widths, with strings inline null-terminated. The non-inline ID +// is absent from the blob (IndexMapField) and comes from RecordId; a +// non-inline relation IS in the blob at its DBD-declared type +// (MetaDataFieldType), then Convert.ChangeType'd to int. +func decodeHotfixRow(t *Table, plans []fieldPlan, rec *HotfixEntry) ([]any, error) { + r := newBitReader(padRecordData(rec.Data)) + values := make([]any, len(plans)) + + for i := range plans { + p := &plans[i] + + // FieldCache.IndexMapField: set at construction for a non-inline DBD + // id, and forced by ReadHotfixes on parser.IdFieldIndex when the + // Index flag is set. + if p.isNonInlineID || (t.Flags&flagIndex != 0 && i == int(t.IdFieldIndex)) { + values[i] = int64(rec.RecordID) + continue + } + + if p.isNonInlineRel { + if p.arrLength != 0 { + // C# Convert.ChangeType to int[] would throw InvalidCastException. + return nil, fmt.Errorf("field %s: non-inline relation arrays are not supported", p.name) + } + if p.hfKind != kindInt { + return nil, fmt.Errorf("field %s: non-integer non-inline relation is not supported", p.name) + } + v, err := toInt32Checked(rawToInt(r.ReadValue64(p.hfSize), p.hfSize, p.hfSigned)) + if err != nil { + return nil, fmt.Errorf("field %s: %w", p.name, err) + } + values[i] = v + continue + } + + if p.arrLength != 0 { + // FieldCache.Cardinality: the CardinalityAttribute arrLength when + // > 1, else the default 1 — arrLength elements either way. + switch p.kind { + case kindString: + out := make([]string, p.arrLength) + for j := range out { + out[j] = r.ReadCString() + } + values[i] = out + case kindFloat: + out := make([]float32, p.arrLength) + for j := range out { + out[j] = math.Float32frombits(uint32(r.ReadValue64(32))) + } + values[i] = out + default: + if p.size == 64 && !p.signed { + out := make([]uint64, p.arrLength) + for j := range out { + out[j] = r.ReadValue64(64) + } + values[i] = out + } else { + out := make([]int64, p.arrLength) + for j := range out { + out[j] = rawToInt(r.ReadValue64(p.size), p.size, p.signed).(int64) + } + values[i] = out + } + } + continue + } + + switch p.kind { + case kindString: + values[i] = r.ReadCString() + case kindFloat: + values[i] = math.Float32frombits(uint32(r.ReadValue64(32))) + default: + values[i] = rawToInt(r.ReadValue64(p.size), p.size, p.signed) + } + } + // C# never validates that the blob is fully consumed; neither do we. + return values, nil +} + +// toInt32Checked mirrors Convert.ChangeType(value, typeof(int)): value- +// preserving, overflow-checked. +func toInt32Checked(v any) (int64, error) { + switch x := v.(type) { + case int64: + if x < math.MinInt32 || x > math.MaxInt32 { + return 0, fmt.Errorf("relation value %d overflows int32", x) + } + return x, nil + case uint64: + if x > math.MaxInt32 { + return 0, fmt.Errorf("relation value %d overflows int32", x) + } + return int64(x), nil + } + return 0, fmt.Errorf("relation value has unexpected type %T", v) +} diff --git a/tools/db2tool/wdc/row.go b/tools/db2tool/wdc/row.go index d8faf7ee8e..eb62778078 100644 --- a/tools/db2tool/wdc/row.go +++ b/tools/db2tool/wdc/row.go @@ -32,6 +32,15 @@ type fieldPlan struct { isNonInlineRel bool isNonInlineID bool isID bool + + // FieldCache.MetaDataFieldType view, used only by the hotfix decoder: + // a non-inline relation is read from a hotfix blob at its DBD-declared + // type (then Convert.ChangeType'd to int), while kind/size/signed above + // carry the typeof(int) override. Identical to kind/size/signed for + // every other field. + hfKind colKind + hfSize int + hfSigned bool } // Row is one decoded record; Values align 1:1 with Decoded.ColumnNames. @@ -87,6 +96,9 @@ func buildFieldPlans(def dbd.DBDefinition, version dbd.VersionDefinitions, build default: return nil, fmt.Errorf("column %q: unable to construct field type from %q", d.Name, col.Type) } + // Capture the DBD-declared mapping (MetaDataFieldType) before the + // non-inline-relation override — the hotfix decoder reads that type. + p.hfKind, p.hfSize, p.hfSigned = p.kind, p.size, p.signed // DBCDBuilder: a non-inline relation is always typeof(int), regardless // of the DBD-declared type. if p.isNonInlineRel { From dcdd45f980e068af045b1fadb755a27e258bd2bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Hillerstr=C3=B6m?= Date: Thu, 16 Jul 2026 17:29:45 +0200 Subject: [PATCH 5/8] db2tool: remove the .NET DB2ToSqlite tool make db / make ptrdb have run the pure-Go extractor since the previous commits; the dotnet tool, its vendored DLLs (TACTSharp, DBCD, DBCD.IO, DBDefsLib), the CDN cache, and the solution file are no longer needed. - delete tools/DB2ToSqlite/ (csproj, Program.cs, Helpers/, references/*.dll, appsettings, launch configs) and wowsims-mop.sln (it contained only this project) - the gitignored artifact dirs (dbfilesclient/, DBDCache/, listfile.csv) already live under tools/db2tool/; add tools/db2tool/caches/ (optional hotfix-cache scan dir) to .gitignore - docs/commands.md: make db now needs a local WoW install (Settings.BaseDir) instead of dotnet 9 --- .gitignore | 1 + docs/commands.md | 2 +- tools/DB2ToSqlite/.gitignore | 422 ------------------ tools/DB2ToSqlite/.vscode/launch.json | 10 - tools/DB2ToSqlite/DB2ToSqliteTool.csproj | 28 -- tools/DB2ToSqlite/Helpers/BindableSettings.cs | 69 --- tools/DB2ToSqlite/Helpers/DBCacheParser.cs | 51 --- tools/DB2ToSqlite/Helpers/HotfixManager.cs | 121 ----- tools/DB2ToSqlite/Helpers/SQLiteDbCreator.cs | 120 ----- .../DB2ToSqlite/Helpers/SqliteDataInserter.cs | 104 ----- tools/DB2ToSqlite/Program.cs | 126 ------ .../Properties/launchSettings.json | 23 - .../DB2ToSqlite/appsettings.Development.json | 8 - tools/DB2ToSqlite/appsettings.json | 64 --- tools/DB2ToSqlite/references/DBCD.IO.dll | Bin 265216 -> 0 bytes tools/DB2ToSqlite/references/DBCD.dll | Bin 37888 -> 0 bytes tools/DB2ToSqlite/references/DBDefsLib.dll | Bin 22016 -> 0 bytes tools/DB2ToSqlite/references/TACTSharp.dll | Bin 65536 -> 0 bytes wowsims-mop.sln | 29 -- 19 files changed, 2 insertions(+), 1176 deletions(-) delete mode 100644 tools/DB2ToSqlite/.gitignore delete mode 100644 tools/DB2ToSqlite/.vscode/launch.json delete mode 100644 tools/DB2ToSqlite/DB2ToSqliteTool.csproj delete mode 100644 tools/DB2ToSqlite/Helpers/BindableSettings.cs delete mode 100644 tools/DB2ToSqlite/Helpers/DBCacheParser.cs delete mode 100644 tools/DB2ToSqlite/Helpers/HotfixManager.cs delete mode 100644 tools/DB2ToSqlite/Helpers/SQLiteDbCreator.cs delete mode 100644 tools/DB2ToSqlite/Helpers/SqliteDataInserter.cs delete mode 100644 tools/DB2ToSqlite/Program.cs delete mode 100644 tools/DB2ToSqlite/Properties/launchSettings.json delete mode 100644 tools/DB2ToSqlite/appsettings.Development.json delete mode 100644 tools/DB2ToSqlite/appsettings.json delete mode 100644 tools/DB2ToSqlite/references/DBCD.IO.dll delete mode 100644 tools/DB2ToSqlite/references/DBCD.dll delete mode 100644 tools/DB2ToSqlite/references/DBDefsLib.dll delete mode 100644 tools/DB2ToSqlite/references/TACTSharp.dll delete mode 100644 wowsims-mop.sln diff --git a/.gitignore b/.gitignore index 3b24f89a6e..1752d98686 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ tools/db2tool/refs/ tools/db2tool/listfile.csv tools/db2tool/DBDCache/ tools/db2tool/dbfilesclient/ +tools/db2tool/caches/ diff --git a/docs/commands.md b/docs/commands.md index 864d1f99fc..742274807e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -70,7 +70,7 @@ make wowsimmop make simdb # Generate data from WoW client files -# Requires dotnet 9 to run +# Requires a local WoW install; point Settings.BaseDir in the settings file at it # Uses tools/database/generator-settings.json for settings # Also runs make simdb # This is what you will use most of the time for generation diff --git a/tools/DB2ToSqlite/.gitignore b/tools/DB2ToSqlite/.gitignore deleted file mode 100644 index 04e6b849d9..0000000000 --- a/tools/DB2ToSqlite/.gitignore +++ /dev/null @@ -1,422 +0,0 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Local History for Visual Studio Code -.history/ - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp - -# JetBrains Rider -*.sln.iml - -TactKey.csv - -# Other stuff -dbcs -cache -caches -temp -manifests -knownPushIDs.json -*.csv -*.csv.bak -*.txt -versionHistory.json -WTL.db -WTL.db-journal -extract -dump -db.db -listfile.csv -dbfilesclient -DBDCache -wowsims.db -.idea \ No newline at end of file diff --git a/tools/DB2ToSqlite/.vscode/launch.json b/tools/DB2ToSqlite/.vscode/launch.json deleted file mode 100644 index 9dae3d4450..0000000000 --- a/tools/DB2ToSqlite/.vscode/launch.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - - - ] -} \ No newline at end of file diff --git a/tools/DB2ToSqlite/DB2ToSqliteTool.csproj b/tools/DB2ToSqlite/DB2ToSqliteTool.csproj deleted file mode 100644 index aea61d1072..0000000000 --- a/tools/DB2ToSqlite/DB2ToSqliteTool.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - net9.0 - enable - enable - - - - - .\references\DBDC.IO.dll - - - \references\DBCD.dll - - - \references\TACTSharp.dll - - - \references\DBDefsLib.dll - - - - - - - - diff --git a/tools/DB2ToSqlite/Helpers/BindableSettings.cs b/tools/DB2ToSqlite/Helpers/BindableSettings.cs deleted file mode 100644 index f2a1b835f6..0000000000 --- a/tools/DB2ToSqlite/Helpers/BindableSettings.cs +++ /dev/null @@ -1,69 +0,0 @@ -using TACTSharp; - -namespace DB2ToSqliteTool.Helpers; - -public class BindableSettings : Settings -{ - public new string Region - { - get => base.Region; - set => base.Region = value; - } - - public new string Product - { - get => base.Product; - set => base.Product = value; - } - - public new RootInstance.LocaleFlags Locale - { - get => base.Locale; - set => base.Locale = value; - } - - public new RootInstance.LoadMode RootMode - { - get => base.RootMode; - set => base.RootMode = value; - } - - public new string? BaseDir - { - get => base.BaseDir; - set => base.BaseDir = value; - } - - public new string? BuildConfig - { - get => base.BuildConfig; - set => base.BuildConfig = value; - } - - public new string? CDNConfig - { - get => base.CDNConfig; - set => base.CDNConfig = value; - } - - public new string CacheDir - { - get => base.CacheDir; - set => base.CacheDir = value; - } - - public new bool ListfileFallback - { - get => base.ListfileFallback; - set => base.ListfileFallback = value; - } - - public new string ListfileURL - { - get => base.ListfileURL; - set => base.ListfileURL = value; - } - - public List GameTables { get; set; } = []; - public string GameTablesOutDirectory { get; set; } = ""; -} diff --git a/tools/DB2ToSqlite/Helpers/DBCacheParser.cs b/tools/DB2ToSqlite/Helpers/DBCacheParser.cs deleted file mode 100644 index 49ebfbcfb0..0000000000 --- a/tools/DB2ToSqlite/Helpers/DBCacheParser.cs +++ /dev/null @@ -1,51 +0,0 @@ -namespace DB2ToSqliteTool.Helpers; - -public struct HotfixEntry -{ - public uint regionID; - public int pushID; - public uint uniqueID; - public uint tableHash; - public uint recordID; - public int dataSize; - public byte status; - public byte[] data; -} - -//https://github.com/Marlamin/wow.tools.local/blob/main/Services/DBCacheParser.cs -public class DBCacheParser -{ - public int build; - public List hotfixes = []; - - public DBCacheParser(string filename) - { - using (var fs = File.Open(filename, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) - using (var bin = new BinaryReader(fs)) - { - var hotfix = new HotfixEntry(); - bin.ReadUInt32(); // Signature - var version = bin.ReadUInt32(); - if (version != 9) - //Console.WriteLine("Unsupported DBCache version " + version + ", skipping"); - return; - build = bin.ReadInt32(); - bin.BaseStream.Position += 32; - - while (bin.BaseStream.Position < bin.BaseStream.Length) - { - bin.ReadUInt32(); // Signature - hotfix.regionID = bin.ReadUInt32(); - hotfix.pushID = bin.ReadInt32(); - hotfix.uniqueID = bin.ReadUInt32(); - hotfix.tableHash = bin.ReadUInt32(); - hotfix.recordID = bin.ReadUInt32(); - hotfix.dataSize = bin.ReadInt32(); - hotfix.status = bin.ReadByte(); - bin.ReadBytes(3); - hotfix.data = bin.ReadBytes(hotfix.dataSize); - hotfixes.Add(hotfix); - } - } - } -} diff --git a/tools/DB2ToSqlite/Helpers/HotfixManager.cs b/tools/DB2ToSqlite/Helpers/HotfixManager.cs deleted file mode 100644 index d6996ad43b..0000000000 --- a/tools/DB2ToSqlite/Helpers/HotfixManager.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Text.Json; -using DBCD.IO; - -//https://github.com/Marlamin/wow.tools.local/blob/main/Services/HotfixManager.cs -namespace DB2ToSqliteTool.Helpers; - -public static class HotfixManager -{ - public static Dictionary HotfixReaders = []; - public static Dictionary> DbcacheParsers = []; - public static Dictionary? PushIdDetected = []; - - public static Dictionary TableNames = Directory.EnumerateFiles("DBDCache/") - .ToDictionary(x => Hash(Path.GetFileNameWithoutExtension(x).ToUpper()), - x => Path.GetFileNameWithoutExtension(x)); - - private static void LoadPushIDs() - { - PushIdDetected = - JsonSerializer.Deserialize>(File.ReadAllText("knownPushIDs.json")); - } - - private static void SavePushIDs() - { - File.WriteAllText("knownPushIDs.json", JsonSerializer.Serialize(PushIdDetected)); - } - - public static void LoadCaches(string wowLocation) - { - if (!File.Exists("knownPushIDs.json")) - SavePushIDs(); - - LoadPushIDs(); - - Console.WriteLine("Reloading all hotfixes.."); - HotfixReaders.Clear(); - - if (Directory.Exists("caches")) - foreach (var file in Directory.GetFiles("caches", "*.bin", SearchOption.AllDirectories)) - { - var reader = new HotfixReader(file); - if (!HotfixReaders.ContainsKey((uint)reader.BuildId)) - HotfixReaders.Add((uint)reader.BuildId, reader); - - HotfixReaders[(uint)reader.BuildId].CombineCache(file); - - if (!DbcacheParsers.ContainsKey((uint)reader.BuildId)) - DbcacheParsers.Add((uint)reader.BuildId, []); - - var newCache = new DBCacheParser(file); - DbcacheParsers[(uint)reader.BuildId].Add(newCache); - - var newPushIDs = newCache.hotfixes.Where(x => x.pushID > 0 && PushIdDetected != null && !PushIdDetected.ContainsKey(x.pushID)) - .Select(x => x.pushID).ToList(); - foreach (var newPushId in newPushIDs) - { - PushIdDetected?.TryAdd(newPushId, DateTime.Now); - Console.WriteLine("Detected new pushID " + newPushId + " at " + DateTime.Now.ToShortTimeString()); - } - - Console.WriteLine("Loaded hotfixes from caches directory for build " + reader.BuildId); - } - - - foreach (var file in Directory.GetFiles(wowLocation, "DBCache.bin", SearchOption.AllDirectories)) - { - var reader = new HotfixReader(file); - if (!HotfixReaders.ContainsKey((uint)reader.BuildId)) - HotfixReaders.Add((uint)reader.BuildId, reader); - - HotfixReaders[(uint)reader.BuildId].CombineCache(file); - - if (!DbcacheParsers.ContainsKey((uint)reader.BuildId)) - DbcacheParsers.Add((uint)reader.BuildId, []); - - var newCache = new DBCacheParser(file); - DbcacheParsers[(uint)reader.BuildId].Add(newCache); - - var newPushIDs = newCache.hotfixes.Where(x => x.pushID > 0 && PushIdDetected != null && !PushIdDetected.ContainsKey(x.pushID)) - .Select(x => x.pushID).ToList(); - foreach (var newPushId in newPushIDs) - { - PushIdDetected?.TryAdd(newPushId, DateTime.Now); - Console.WriteLine("Detected new pushID " + newPushId + " at " + DateTime.Now.ToShortTimeString()); - } - - Console.WriteLine("Loaded hotfixes from client for build " + reader.BuildId); - } - - SavePushIDs(); - } - - public static void Clear() - { - HotfixReaders.Clear(); - DbcacheParsers.Clear(); - } - - private static uint Hash(string s) - { - var sHashtable = new uint[] - { - 0x486E26EE, 0xDCAA16B3, 0xE1918EEF, 0x202DAFDB, - 0x341C7DC7, 0x1C365303, 0x40EF2D37, 0x65FD5E49, - 0xD6057177, 0x904ECE93, 0x1C38024F, 0x98FD323B, - 0xE3061AE7, 0xA39B0FA1, 0x9797F25F, 0xE4444563 - }; - - uint v = 0x7fed7fed; - var x = 0xeeeeeeee; - for (var i = 0; i < s.Length; i++) - { - var c = (byte)s[i]; - v += x; - v ^= sHashtable[(c >> 4) & 0xf] - sHashtable[c & 0xf]; - x = x * 33 + v + c + 3; - } - - return v; - } -} diff --git a/tools/DB2ToSqlite/Helpers/SQLiteDbCreator.cs b/tools/DB2ToSqlite/Helpers/SQLiteDbCreator.cs deleted file mode 100644 index 17409f4cd4..0000000000 --- a/tools/DB2ToSqlite/Helpers/SQLiteDbCreator.cs +++ /dev/null @@ -1,120 +0,0 @@ -using DBDefsLib; -using Microsoft.Data.Sqlite; - -namespace DB2ToSqliteTool.Helpers; - -public static class SqliteDbCreator -{ - public static void CreateDatabaseWithDefinitions(Dictionary definitions, - string sqliteFilePath, uint build) - { - if (File.Exists(sqliteFilePath)) File.Delete(sqliteFilePath); - - var connectionString = new SqliteConnectionStringBuilder - { - DataSource = sqliteFilePath, - Mode = SqliteOpenMode.ReadWriteCreate - }.ToString(); - - using var connection = new SqliteConnection(connectionString); - connection.Open(); - - using (var pragmaCommand = connection.CreateCommand()) - { - pragmaCommand.CommandText = "PRAGMA foreign_keys = ON;"; - pragmaCommand.ExecuteNonQuery(); - } - - using (var transaction = connection.BeginTransaction()) - { - foreach (var kvp in definitions) - { - var tableName = kvp.Key; - var dbDef = kvp.Value; - - var versionDef = dbDef.versionDefinitions.LastOrDefault(x => x.builds.Any(y => y.build == build)); - - var columnDefinitionsSql = new List(); - var foreignKeysSql = new List(); - - foreach (var def in versionDef.definitions) - { - if (!dbDef.columnDefinitions.TryGetValue(def.name, out var colDef)) - throw new Exception($"Column definition for {def.name} not found in table {tableName}"); - - if (def.arrLength == 0) - { - var sqliteType = MapToSqLiteType(colDef.type); - var nullability = !string.IsNullOrEmpty(colDef.foreignTable) && - !string.IsNullOrEmpty(colDef.foreignColumn) && - !def.isID - ? " NULL" - : ""; - var columnSql = $"[{def.name}] {sqliteType}{nullability}"; - if (def.isID) columnSql += " PRIMARY KEY"; - columnDefinitionsSql.Add(columnSql); - - if (!string.IsNullOrEmpty(colDef.foreignTable) && - !string.IsNullOrEmpty(colDef.foreignColumn)) - foreignKeysSql.Add( - $"CREATE INDEX IF NOT EXISTS IX_{tableName}_{def.name} ON [{tableName}] ([{def.name}])"); - } - else - { - var mainColumnSql = $"[{def.name}] TEXT"; - - columnDefinitionsSql.Add(mainColumnSql); - - // (For example, an "int" or "uint" becomes INTEGER, "float" becomes REAL, etc.) - var elementType = MapToSqLiteType(colDef.type); - - // Create a generated column for each array index. - for (var i = 0; i < def.arrLength; i++) - { - // Generated column syntax: - // [ColumnName_i] GENERATED ALWAYS AS (json_extract([ColumnName], '$[i]')) VIRTUAL - var genColumn = - $"[{def.name}_{i}] {elementType} GENERATED ALWAYS AS (json_extract([{def.name}], '$[{i}]')) VIRTUAL"; - columnDefinitionsSql.Add(genColumn); - } - } - } - - var allColumns = new List(columnDefinitionsSql); - - - var createTableSql = $"CREATE TABLE IF NOT EXISTS [{tableName}] ({string.Join(", ", allColumns)});"; - - using (var command = connection.CreateCommand()) - { - command.CommandText = createTableSql; - command.Transaction = transaction; - command.ExecuteNonQuery(); - } - - foreach (var indexSql in foreignKeysSql) - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = indexSql; - cmd.Transaction = transaction; - cmd.ExecuteNonQuery(); - } - } - - transaction.Commit(); - } - - connection.Close(); - } - - private static string MapToSqLiteType(string type) - { - return type switch - { - "int" or "uint" => "INTEGER", - "float" => "REAL", - "string" or "locstring" => "TEXT", - _ => throw new Exception("Unsupported type: " + type) - }; - } -} diff --git a/tools/DB2ToSqlite/Helpers/SqliteDataInserter.cs b/tools/DB2ToSqlite/Helpers/SqliteDataInserter.cs deleted file mode 100644 index 25e438c67a..0000000000 --- a/tools/DB2ToSqlite/Helpers/SqliteDataInserter.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Text.Json; -using DBCD; -using DBDefsLib; -using Microsoft.Data.Sqlite; - -namespace DB2ToSqliteTool.Helpers; - -public static class SqliteDataInserter -{ - public static void InsertRows(IDBCDStorage storage, string tableName, Structs.DBDefinition dbDef, - SqliteConnection connection, uint build) - { - var versionDef = dbDef.versionDefinitions.LastOrDefault(x => x.builds.Any(y => y.build == build)); - - var columnNames = versionDef.definitions.Select(def => def.name).ToList(); - - - var pkDefinition = versionDef.definitions.FirstOrDefault(def => def.isID); - - var pkColumn = pkDefinition.name; - - - var columnsPart = string.Join(", ", columnNames.Select(c => $"[{c}]")); - var valuesPart = string.Join(", ", columnNames.Select(c => "@" + c)); - - - var updateColumns = columnNames.Where(c => c != pkColumn).ToList(); - var updateClause = updateColumns.Any() - ? "DO UPDATE SET " + string.Join(", ", updateColumns.Select(c => $"[{c}] = excluded.[{c}]")) - : "DO NOTHING"; - - - var upsertSql = - $"INSERT INTO [{tableName}] ({columnsPart}) VALUES ({valuesPart}) ON CONFLICT([{pkColumn}]) {updateClause};"; - - - using var transaction = connection.BeginTransaction(); - using (var command = connection.CreateCommand()) - { - command.Transaction = transaction; - - // create indexes - foreach (var col in columnNames) - { - var colDef = versionDef.definitions.FirstOrDefault(d => d.name == col); - if (colDef.isRelation) - { - command.CommandText = $"CREATE INDEX IF NOT EXISTS idx_{col.ToLower()} ON {tableName} ({col});"; - try - { - command.ExecuteNonQuery(); - } - catch (SqliteException se) - { - Console.WriteLine("Error executing command:"); - Console.WriteLine(command.CommandText); - foreach (SqliteParameter param in command.Parameters) - Console.WriteLine($"{param.ParameterName} = {param.Value}"); - Console.WriteLine("Exception: " + se.Message); - throw; - } - } - } - - - command.CommandText = upsertSql; - - // store a list of all relations to create indexes for those columns for faster lookups - var indexList = new HashSet(); - foreach (var kvp in storage.Values) - { - command.Parameters.Clear(); - - foreach (var col in columnNames) - { - var value = kvp[col] ?? DBNull.Value; - var colDef = versionDef.definitions.FirstOrDefault(d => d.name == col); - if (colDef.isRelation && value == (object)0) value = DBNull.Value; - if (colDef.arrLength > 0) - if (value is Array arr) - value = JsonSerializer.Serialize(arr); - - command.Parameters.AddWithValue("@" + col, value); - } - - try - { - command.ExecuteNonQuery(); - } - catch (SqliteException se) - { - Console.WriteLine("Error executing command:"); - Console.WriteLine(command.CommandText); - foreach (SqliteParameter param in command.Parameters) - Console.WriteLine($"{param.ParameterName} = {param.Value}"); - Console.WriteLine("Exception: " + se.Message); - throw; - } - } - } - - transaction.Commit(); - } -} diff --git a/tools/DB2ToSqlite/Program.cs b/tools/DB2ToSqlite/Program.cs deleted file mode 100644 index b84029acc8..0000000000 --- a/tools/DB2ToSqlite/Program.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Diagnostics; -using DB2ToSqliteTool.Helpers; -using DBCD; -using DBCD.Providers; -using DBDefsLib; -using Microsoft.Data.Sqlite; -using TACTSharp; - -var settingsFile = "appsettings.json"; -var databaseFile = "wowsims.db"; - -// Parse command-line arguments for settings and output file overrides. -for (var i = 0; i < args.Length; i++) -{ - if (args[i] == "--settings" || args[i] == "-s") - if (i + 1 < args.Length) - settingsFile = args[i + 1]; - - if (args[i] == "--output" || args[i] == "-output" || args[i] == "-o") - if (i + 1 < args.Length) - databaseFile = args[i + 1]; -} - -// Derive targetDirectory from the databaseFile’s directory (if specified) or fall back to configuration. - -var configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile(settingsFile, false, true) - .Build(); - -var settings = configuration.GetSection("Settings").Get(); -if (settings == null) throw new Exception("Failed to load Settings from configuration."); - -var listFile = new Listfile(); - -listFile.Initialize(new CDN(settings), settings); - -Debug.Assert(settings.BaseDir != null); -var buildInfo = new BuildInfo(Path.Combine(settings.BaseDir, ".build.info"), settings, new CDN(settings)); - -var buildInstance = new BuildInstance(); -var entry = buildInfo.Entries.First(x => x.Product == settings.Product); - -buildInstance.Settings.BuildConfig ??= entry.BuildConfig; -buildInstance.Settings.CDNConfig ??= entry.CDNConfig; - -buildInstance.LoadConfigs(buildInstance.Settings.BuildConfig, buildInstance.Settings.CDNConfig); -buildInstance.Load(); - -var tables = configuration.GetSection("Tables").Get>(); -var gameTables = configuration.GetSection("GameTables").Get>(); - -var gameTablesOutDir = configuration.GetSection("GameTablesOutDirectory").Get() ?? "GameTables"; - -var targetDirectory = configuration.GetValue("TargetDirectory") ?? "dbfilesclient"; - -Directory.CreateDirectory(targetDirectory); -Directory.CreateDirectory(gameTablesOutDir); - -var fsProvider = new FilesystemDBCProvider(targetDirectory, true); -var githubDbdProvider = new GithubDBDProvider(true); - -var dbcd = new DBCD.DBCD(fsProvider, githubDbdProvider); - -var dbDefinitions = new Dictionary(); -var storageMap = new Dictionary(); -if (gameTables != null && gameTables.Any()) - foreach (var gameTable in gameTables) - { - var file = buildInstance.OpenFileByFDID(listFile.GetFDID($"gametables/{gameTable}.txt")); - await File.WriteAllBytesAsync($"{gameTablesOutDir}/{gameTable}.txt", file); - } - -if (tables != null) - foreach (var tableName in tables) - { - var file = buildInstance.OpenFileByFDID(listFile.GetFDID($"{targetDirectory}/{tableName}.db2")); - await File.WriteAllBytesAsync($"{targetDirectory}/{tableName}.db2", file); - - var tableDefStream = githubDbdProvider.StreamForTableName(tableName, entry.Version); - var dbReader = new DBDReader(); - var tableDefinition = dbReader.Read(tableDefStream, true); - - var storage = dbcd.Load(tableName, entry.Version); - - dbDefinitions.Add(tableName, tableDefinition); - storageMap.Add(tableName, storage); - } - -var splitBuild = entry.Version.Split('.'); -if (splitBuild.Length != 4) - throw new Exception("Invalid build!"); - -var buildNumber = uint.Parse(splitBuild[3]); -SqliteDbCreator.CreateDatabaseWithDefinitions(dbDefinitions, databaseFile, buildNumber); - - -if (HotfixManager.HotfixReaders.Count == 0) - HotfixManager.LoadCaches(settings.BaseDir); -if (!HotfixManager.HotfixReaders.TryGetValue(buildNumber, out var hotfixReader)) { - - //throw new Exception("No hotfix found for build " + buildNumber); -} - -var connectionString = new SqliteConnectionStringBuilder -{ - DataSource = databaseFile, - Mode = SqliteOpenMode.ReadWriteCreate -}.ToString(); - -await using var conn = new SqliteConnection(connectionString); - -conn.Open(); - -foreach (var tableName in tables) -{ - var storage = storageMap[tableName]; - if (hotfixReader != null) { - storage.ApplyingHotfixes(hotfixReader); - } - SqliteDataInserter.InsertRows(storage, tableName, dbDefinitions[tableName], conn, buildNumber); -} - -conn.Close(); - -Console.WriteLine("Processing completed."); diff --git a/tools/DB2ToSqlite/Properties/launchSettings.json b/tools/DB2ToSqlite/Properties/launchSettings.json deleted file mode 100644 index 266eaea0f0..0000000000 --- a/tools/DB2ToSqlite/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5221", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7268;http://localhost:5221", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/tools/DB2ToSqlite/appsettings.Development.json b/tools/DB2ToSqlite/appsettings.Development.json deleted file mode 100644 index a91944829f..0000000000 --- a/tools/DB2ToSqlite/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/tools/DB2ToSqlite/appsettings.json b/tools/DB2ToSqlite/appsettings.json deleted file mode 100644 index 066517b255..0000000000 --- a/tools/DB2ToSqlite/appsettings.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "Settings": { - "BaseDir": "F:\\World of Warcraft", - "BuildConfig": "buildConfig", - "CDNConfig": "cdnConfig", - "Region": "us", - "Product": "wow_classic" - }, - "TargetDirectory": "dbfilesclient", - "DatabaseFile": "wowsims.db", - "Tables": [ - "Spell", - "Item", - "GemProperties", - "ItemSet", - "ItemSetSpell", - "ItemSubClass", - "ItemSubClassMask", - "ItemReforge", - "ItemBonus", - "ItemEffect", - "ItemClass", - "ItemRandomProperties", - "ItemExtendedCost", - "ItemRandomSuffix", - "RandPropPoints", - "ItemDamageAmmo", - "ItemDamageOneHand", - "ItemDamageOneHandCaster", - "ItemDamageRanged", - "ItemDamageThrown", - "ItemDamageTwoHand", - "ItemDamageTwoHandCaster", - "ItemDamageWand", - "ItemNameDescription", - "ItemSparse", - "ArmorLocation", - "ItemArmorTotal", - "ItemArmorShield", - "ItemArmorQuality", - "SkillLineAbility", - "Curve", - "CurvePoint", - "Difficulty", - "SpellEquippedItems", - "SpellRadius", - "SpellMisc", - "SpellLevels", - "SpellName", - "ScalingStatDistribution", - "SpellCooldowns", - "SpellScaling", - "SpellClassOptions", - "SpellItemEnchantment", - "SpellEffect" - ] -} diff --git a/tools/DB2ToSqlite/references/DBCD.IO.dll b/tools/DB2ToSqlite/references/DBCD.IO.dll deleted file mode 100644 index c16e55a117f1928d71d58da1ffe275c2deab8755..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 265216 zcmeEv34k3%xpk-Sy?wV?()Uj8OeSGwvP|gY&V(H&LD`WgyP!@2N&pcOfyP`=WSDVW zL1j_EE#d~a@B6;tf(uAcQBiQg4c~JYcOCw7zOQ<#S&~4|_ufxn`gT>Fs;;W8t~z!4 ztLkGmywu4#j+4jV{r5Z0NAb&l2g`Bp!z6+``aj;`e6W1$l8??i`qm{UoxQ0waQ>F? ztSzUVJ8;Hn=baZ`FmU>r16wXUZ(!4T1BV}X;=sA#Q_fu7-dauS>aCUvTC{7r=dy zYs$s4ntVqvMA^7{OKZy+@Q}PAj}HEKJRXvN2P5~Zx14!S2q5$7AkUIkWg70#B$l3i zU_ddbm66F+OWoYKoWP?(kG&??LcmlIBwdJPdomZjx(Pjk9BhkySszcj#CLb5b8LqP>w}==A6%=u2Dwc4fa4F z+DdCr{8k*->kaq9S!-_`#+mb6V{^CZkN;kA{j#bC4Rc0j?!e!xdeVDI1W z8a&`z#=$fXI09K4`6~|PfvKP0`8uZk-Jjff-2tdPN&9I(yYsaN++rL=$c09}BrzWQ z>pQQHk$3;*&KqLnl9wnYDW6!VAbARzf<(Uh8fu2jm^HH(qr|8)xo=3)>AExJDa42p$A@+O^ zJ?|0ElVi`f((_jFJTvxuBR$!b6}N=3=T>^&fPk)7r8M{`(b!qHcwB=dIwl=se zxc>RUer%Kb<8ZO_gQ_=F^umQ~lXtN_AF@~<_fDL@8O`fM_`{8qe|>K25df}Q;pRfr zagz@~eb2n}XoLdhw?+_J@p9v9n4ITstKs#i1u%1-M*L~U!HuK^R^p6BET zhr3ry+<}uR?_4}wEyS%1PBMf~U320)S2Iq$a~X)OS+d2 zMK0-IJ_*;zCA_P7?O;9|1xpWeSLBi|=4fPO1g;IO72WggKBe1c2Dy?@k9ePy~1yq<`=~?>1i++I3w>B&!ngM6Fpxe zo=H#hBYM72Jd>X0C-i)}cqToKc%CAjNlzo5cn*=zq^A+jy~Q)>X~c6k@l1Le@hpjF z($k11t5F|>3-Iiq*|Z*mdtDj%e&Ua3j{c7o|Ia7>cw*^)l=#0R@kb{>|D(nKn#3RF zrvEYYZ%&wt?xCK?isvTdIjo+?iRVeiv#FlPi|0Yc6BW!fQ71@qmGRtNJs&5Y3ykMp z>UpAgI;-|q?{Jy)4D~$=&PL=ELvQ-o2u>o}bA!j@x4Nx?!B{#S@oy`+;S=B=JQ0WX zVmTPaFCU7YE0zK{*zm3&>@Hm&AkAjDfnfzSy22`63s0<5oa{HP_J7vL!PXeOiUAgIs6#!Hl zZOzVN$t!JbJsJMjm%2yWN(h|Fx0i>1QLN|r$Vk8JI(|h&6=7 zo7JXM6l~66%cv5Y>mN9|bvlu`#?-v_@=(dE=SQwUaHATW0e^J4WG(*XW@|ByuP@3)Gvl-!{IdB zy?k+WbL1?_W#HO`!0yq}s^fdzqjk^s{KA!+_P!4J(L%DG(2U7bQK#W%z&BM~PqJ6l zs}0KK8s7TdPTf4J)TyZ{%B7jb^Iyb0QBpfyZ+r z4>{rUbs&bL1t^c(7=IoD@$-EB;Z@*b+W29L_+dJ3_c¨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 diff --git a/tools/DB2ToSqlite/references/DBCD.dll b/tools/DB2ToSqlite/references/DBCD.dll deleted file mode 100644 index 4b7b59950ebbfd3a52a3b4cb17d8eb5690bf655f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37888 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 diff --git a/tools/DB2ToSqlite/references/DBDefsLib.dll b/tools/DB2ToSqlite/references/DBDefsLib.dll deleted file mode 100644 index 8c94ac7fc51d914a78b7dd4cf1669b7b7c152fd2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22016 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 Zhi&x42{_|Pj{{j0LGfMyf diff --git a/tools/DB2ToSqlite/references/TACTSharp.dll b/tools/DB2ToSqlite/references/TACTSharp.dll deleted file mode 100644 index 7e873dd5e14dd47def13e45e3f6ed2c04e99ec27..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65536 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 m?-bZWo0iDohv7ty{p|A(m;$&Kgr8hi-=ewx+2 Date: Fri, 17 Jul 2026 17:48:43 +0200 Subject: [PATCH 6/8] Skip writing db.bin/db.json when database contents are unchanged The binary protobuf encoding is nondeterministic (map ordering), so a no-op regeneration previously churned the .bin files on every run. The JSON output is deterministic and covers all UIDatabase fields, so compare the freshly built JSON against the file on disk and skip writing both files when identical. --- tools/database/database.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tools/database/database.go b/tools/database/database.go index 11b15583c3..96632b8e2e 100644 --- a/tools/database/database.go +++ b/tools/database/database.go @@ -297,8 +297,15 @@ func ReadDatabaseFromJson(jsonStr string) *WowDatabase { } func (db *WowDatabase) WriteBinaryAndJson(binFilePath, jsonFilePath string) { + jsonBytes := db.toJsonBytes() + if existing, err := os.ReadFile(jsonFilePath); err == nil && bytes.Equal(existing, jsonBytes) { + log.Printf("No changes detected, skipping write of %s and %s", binFilePath, jsonFilePath) + return + } db.WriteBinary(binFilePath) - db.WriteJson(jsonFilePath) + if err := os.WriteFile(jsonFilePath, jsonBytes, 0666); err != nil { + log.Fatalf("[ERROR] Failed to write %s: %s", jsonFilePath, err.Error()) + } } func (db *WowDatabase) WriteBinary(binFilePath string) { @@ -320,8 +327,12 @@ func (db *WowDatabase) WriteBinary(binFilePath string) { } func (db *WowDatabase) WriteJson(jsonFilePath string) { - // Also write in JSON format, so we can manually inspect the contents. - // Write it out line-by-line, so we can have 1 line / item, making it more human-readable. + os.WriteFile(jsonFilePath, db.toJsonBytes(), 0666) +} + +// Serializes in JSON format, so we can manually inspect the contents. +// Written out line-by-line, so we can have 1 line / item, making it more human-readable. +func (db *WowDatabase) toJsonBytes() []byte { uidb := db.ToUIProto() buffer := new(bytes.Buffer) @@ -356,5 +367,5 @@ func (db *WowDatabase) WriteJson(jsonFilePath string) { tools.WriteProtoArrayToBuffer(uidb.SpellEffects, buffer, "spellEffects") buffer.WriteString("\n") buffer.WriteString("}") - os.WriteFile(jsonFilePath, buffer.Bytes(), 0666) + return buffer.Bytes() } From 11d470f7fe62d557e3a689e9b7ff9167814761b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Hillerstr=C3=B6m?= Date: Thu, 23 Jul 2026 02:00:14 +0200 Subject: [PATCH 7/8] Remove migration doc and clean up comments --- docs/db2tool-migration-plan.md | 433 ------------------------ makefile | 4 +- tools/db2tool/NOTICES.md | 21 +- tools/db2tool/config/config.go | 19 +- tools/db2tool/dbd/dbd.go | 20 +- tools/db2tool/dbd/dbd_test.go | 9 +- tools/db2tool/dbd/fetch.go | 9 +- tools/db2tool/dbd/select.go | 14 +- tools/db2tool/golden_test.go | 24 +- tools/db2tool/hotfix_golden_test.go | 19 +- tools/db2tool/internal/golden/golden.go | 16 +- tools/db2tool/main.go | 45 ++- tools/db2tool/sqlite/insert.go | 15 +- tools/db2tool/sqlite/schema.go | 19 +- tools/db2tool/sqlite/sqlite_test.go | 13 +- tools/db2tool/tact/blte.go | 9 +- tools/db2tool/tact/buildinfo.go | 7 +- tools/db2tool/tact/cascidx.go | 2 +- tools/db2tool/tact/config.go | 2 +- tools/db2tool/tact/fdid.go | 7 +- tools/db2tool/tact/listfile.go | 8 +- tools/db2tool/wdc/bitreader.go | 22 +- tools/db2tool/wdc/hotfix.go | 86 ++--- tools/db2tool/wdc/row.go | 76 ++--- tools/db2tool/wdc/wdc5.go | 31 +- tools/db2tool/wdc/wdc5_test.go | 11 +- 26 files changed, 232 insertions(+), 709 deletions(-) delete mode 100644 docs/db2tool-migration-plan.md diff --git a/docs/db2tool-migration-plan.md b/docs/db2tool-migration-plan.md deleted file mode 100644 index 85a59b32df..0000000000 --- a/docs/db2tool-migration-plan.md +++ /dev/null @@ -1,433 +0,0 @@ -# Migration Plan: Reimplement `tools/DB2ToSqlite` (.NET 9) in Pure Go - -Author target: wowsims/mop maintainer, Go-fluent, repo-familiar; phases are contributor-handoff-ready. This is a **plan, not an implementation**. - -Two hard constraints govern every decision below: - -- **Minimal API surface.** Each ported component implements *only* what the configured `Tables[]` / `GameTables[]` → `wowsims.db` path exercises for the current live MoP-Classic build. No whole-library ports. -- **Licensing.** Per-file notices as spelled out in §4; CC BY-SA `.dbd` data is fetched at build time, never vendored into the MIT tree; `WoW.txt` TACT keys are user-supplied, never vendored. - -> **Revised after maintainer review + verification against the vendored `.db2` files and the maintainer's live install.** (1) the build is **not pinned** — the tool tracks the live game and is re-run on every patch/hotfix (§1); (2) the committed `db.json` is built **with hotfixes**, so Phase D is **required** for parity (§6, §7 H4); (3) **the current tool uses NO TACT keys** — verified: every encrypted DB2 section in the vendored `.db2` is zero-filled and skipped, so the shipped data simply omits a small amount of pre-release content. Decrypting (Salsa20 + `WoW.txt`) is an **optional future enhancement**, not needed for parity (§7 C1, §4). (4) **[adversarial review 2026-07-16] the current tool never actually reads local CASC** — `Program.cs:41` constructs `BuildInstance()` without passing the JSON-bound settings, so `Settings.BaseDir` stays null and TACTSharp fetches configs, group/file indices, encoding, root, and every `.db2`/gametable byte from the **Blizzard CDN** into `tools/DB2ToSqlite/cache/` (~1.2 GB; blob timestamps match the vendored `.db2` to the minute). The local install supplies only `.build.info` and `DBCache.bin` (§2.1 step 5, §6 Phase B, §7 C3). The live install's local CASC files were separately verified *present and well-formed* — `.build.info`, `.idx` v7, archives, WoW root + TVFS (§7 C3, §10 Q4) — but they are **not what the current tool reads**; the planned local-first port is a deliberate behavior change, de-riskable with a one-line dotnet patch before any Go is written (§1 Stance, §6 Phase B). - ---- - -## 1. Executive summary - -`make db` / `make ptrdb` currently run a .NET 9 tool (`tools/DB2ToSqlite`) that extracts the live World of Warcraft build (MoP Classic, `wow_classic` / PTR `wow_classic_ptr`; the build is identified via the local install's `.build.info`, but the file bytes come from the Blizzard CDN — see the revision note above) into `tools/database/wowsims.db` plus 8 basestats `.txt` files, then run the existing Go generator (`tools/database/gen_db/*.go`) to emit the shipped `assets/database/db.{bin,json}`. This plan replaces the .NET half with a pure-Go tool at `tools/db2tool/`, removing dotnet from the build entirely. - -**What changes** - -- The first stage of `make db` / `make ptrdb` changes from `dotnet run` to `go run ./tools/db2tool ...`. Target names, settings files, and the second (`gen_db`) stage are unchanged. -- Four vendored .NET DLLs (TACTSharp, DBCD, DBCD.IO, DBDefsLib), the NuGet `Microsoft.Data.Sqlite` dependency, the `.csproj`, and the `.sln` entry are deleted. - -**What does not change (the drop-in contract)** - -- **`tools/database/wowsims.db`** — the SQLite schema, generated `[Col_i]` VIRTUAL columns, JSON-array text encoding, and exact-build column set are a frozen integration contract consumed by `tools/database/*.go` (sole reader: `dbhelper.go:22`, `sql.Open("sqlite", DatabasePath)` via `modernc.org/sqlite`). -- **`assets/db_inputs/basestats/*.txt`** — 8 GameTables copied verbatim. -- **`tools/DB2ToSqlite/listfile.csv`** — a *second* output contract (see §5), hardcoded in three downstream Go files. -- **`assets/database/db.{bin,json}` + `leftover_db.{bin,json}`** — the committed, shipped goldens produced by the unchanged `gen_db` stage. These are the true end-to-end acceptance target. - -**Stance** - -- **Pure Go, no cgo.** The existing `modernc.org/sqlite v1.37.0` (pure Go) writes the output — the same driver the reader already uses. All decompression/crypto is stdlib (`compress/zlib`, `crypto/md5`, `encoding/binary`) plus, only if a needed file is ever encrypted, `golang.org/x/crypto/salsa20`. The repo's one cgo file (`sim/lib/library.go`) is a separate `c-shared` target and is not on the `make db` path. -- **Keep the SQLite intermediate.** Do not go direct-to-`dbc`; the schema *is* the contract and keeping it makes the port a true drop-in and gives a clean per-half validation seam. -- **Local-install-first — a deliberate behavior change, not the status quo.** The current tool is CDN-fed (revision note above); the port reads the full local install instead. Same build config → same CKeys → same bytes, so parity is expected, but it must be *proven*: Phase B gate 1 byte-diffs the locally-extracted `.db2` against the CDN-sourced vendored ones. **Cheap de-risk before writing any Go:** TACTSharp's `Settings` fields are public and Program.cs already mutates them, so a one-line dotnet patch (`buildInstance.Settings.BaseDir = settings.BaseDir;`) makes the *current* tool exercise the local path — run it once and byte-diff the outputs (§6 Phase B pre-flight). CDN/Ribbit in Go stays deferred to an explicit, optional phase (§6 Phase C); porting CDN-first instead is the strict-parity fallback if local extraction ever proves incomplete. - -**Live build, not pinned.** The tool always targets **whatever build the local install currently is** (via `.build.info`); `5.5.4.68571` was current at analysis time (listed verbatim in all 72 cached `.dbd`, with build-specific unnamed columns `Field_1_15_3_55112_014` / `Field_1_15_7_59706_054`). It is re-run whenever Blizzard patches or new hotfixes land, so the build number, the required `.dbd` (WoWDBDefs must already contain the new build), and the WDC format version are all **moving targets** the tool must track — not constants to hardcode. PTR differs by `Product = "wow_classic_ptr"` and, concretely, a *different build*: on the live install right now `wow_classic` = `5.5.4.68571` while `wow_classic_ptr` = `5.5.4.67849`. - -**Required per-run inputs (all track the live game, none committed):** the `.dbd` schemas (fetched from WoWDBDefs) and the `listfile.csv` (path→FDID). **No TACT keys are used** — encrypted DB2 sections are skipped (§7 C1); a `WoW.txt` would only be needed if you later choose to decrypt pre-release content (§4). The committed `db.json` is generated **with hotfixes** applied (§6 Phase D). - -**Overall effort: L–XL**, dominated by the WDC5 decoder and the local CASC/TACT reader. - ---- - -## 2. Current pipeline (as-is) - -### 2.1 `make db` data flow - -``` -make db (makefile:249-255) - ├─ cd tools/DB2ToSqlite - │ └─ dotnet run -- -s --output - │ (ptrdb: ptr-generator-settings.json; only Product differs) - │ - │ Program.cs (126 lines), 11 steps: - │ 1. parse --settings/-s, --output/-o - │ 2. load JSON: Settings→BindableSettings:TACTSharp.Settings + Tables[72],GameTables[8], - │ GameTablesOutDirectory="../../assets/db_inputs/basestats", TargetDirectory="dbfilesclient" - │ 3. Listfile.Initialize(CDN, settings) [downloads/caches 148 MB listfile.csv via HTTP, path→FDID] - │ 4. BuildInfo(BaseDir/.build.info) [pick entry where Product==settings.Product] - │ 5. LoadConfigs(BuildConfig,CDNConfig); Load() [configs + group/file indices + encoding + root + install - │ ALL fetched from the Blizzard CDN into cache/ — - │ Program.cs:41 creates BuildInstance() with a fresh default - │ Settings (BaseDir=null, never copied from the JSON settings), - │ so cdn.OpenLocal() is never called; local .idx/data.NNN unread] - │ 6. for GameTables: OpenFileByFDID(GetFDID("gametables/.txt")) → write raw bytes to basestats dir - │ 7. for Tables: OpenFileByFDID(GetFDID("/.db2")) → write /.db2; - │ fetch .dbd; DBCD.Load [TargetDirectory does double duty: listfile-key prefix AND - │ output dir (Program.cs:77-78) — see §7 M4 carve-out] - │ 8. buildNumber = uint.Parse(Version.Split('.')[3]) [= 68571] - │ 9. SqliteDbCreator.CreateDatabaseWithDefinitions(...) [DELETES any existing output DB first - │ (SQLiteDbCreator.cs:11) — every run starts - │ from an empty file — then schema from DBD] - │ 10. HotfixManager.LoadCaches(BaseDir) [best-effort; throw commented out] - │ 11. per table: ApplyingHotfixes; SqliteDataInserter.InsertRows (upsert) - │ - └─ go run tools/database/gen_db/*.go -outDir=./assets -gen=db - reads wowsims.db (dbhelper.go:22) + tools/DB2ToSqlite/listfile.csv (icon map) - + runs tools/database/overrides/{0,1,2}.sql (0.sql/1.sql create item_enchantment_template) - → assets/database/db.{bin,json}, leftover_db.{bin,json} (COMMITTED goldens) -``` - -### 2.2 The four vendored .NET libraries (source not in repo, DLLs only) - -| Library | Upstream | Role in the tool | -|---|---|---| -| TACTSharp | github.com/wowdev/TACTSharp | CASC/TACT client: parse `.build.info`, load build/CDN configs, encoding + root, BLTE-decode, `OpenFileByFDID`; listfile path→FDID (Jenkins96). Has *both* a local-CASC read path (`.idx` + `data.NNN`) and a CDN one — **only the CDN path is exercised here** (BaseDir is never handed to it, §2.1 step 5), including `GroupIndex.Generate` (the four ~120 MB generated group indices in `cache/`). | -| DBCD.IO | github.com/wowdev/DBCD (subproject) | WDC5 binary DB2 decoder + `XFTH` hotfix reader. | -| DBCD | github.com/wowdev/DBCD | Thin orchestration: `Load(table, version)`, `row[col]`, `Values`, `ApplyingHotfixes`. | -| DBDefsLib | github.com/wowdev/WoWDBDefs (`code/C#/DBDefsLib`) | `.dbd` text parser → column/version definitions. | - -Two helpers (`DBCacheParser.cs`, `HotfixManager.cs`) are copied from `github.com/Marlamin/wow.tools.local`. Output SQLite uses NuGet `Microsoft.Data.Sqlite 9.0.3`. - -### 2.3 Downstream Go consumer (unchanged by this migration) - -- `tools/database/dbhelper.go:22` — the *only* reader of `wowsims.db`. -- `tools/database/tables.go` — fixed SQL over named tables + generated `[Col_i]` columns; array base columns parsed as JSON text (`tools/database/utils.go:15` `parseIntArrayField`, `:29` `parseFloatArrayField`). -- `tools/database/icon_loader.go:13` `LoadArtTexturePaths` reads `listfile.csv` (`;`-delimited `FDID;path`), hardcoded at `gen_db/main.go:153`, `gen_protos.go:458`, `tables.go:1123`. -- `tools/database/dbc/spell_scaling.go:12` `//go:embed GameTables/SpellScaling.txt` — a committed copy independent of the extractor run. - ---- - -## 3. Target architecture - -### 3.1 Package layout (single module `github.com/wowsims/mop`, no new module) - -``` -tools/db2tool/ - main.go cobra command (-s/--settings, -o/--output); faithful transcription of Program.cs's 11 steps - config/ settings JSON binding: Settings{Region,Product,BaseDir,BuildConfig,CDNConfig,CacheDir,Locale, - RootMode,ListfileFallback,ListfileURL} + Tables[],GameTables[],GameTablesOutDirectory,TargetDirectory - (CacheDir is bound-but-unused in v1: the local path needs no CDN cache; Phase C would - reintroduce one under tools/db2tool/) - dbd/ .dbd text parser -- BSD-3-Clause (derivative of DBDefsLib) - dbd.go DBDReader.Read + full DBDefinition model (incl. size/isSigned/isNonInline for the WDC5 reader) - select.go exact-build versionDef selection (see §5.5) - wdc/ WDC5 + XFTH decoders -- MIT (derivative of DBCD / DBCD.IO) - bitreader.go byte-exact unaligned little-endian bit reader - wdc5.go header/sections/field-meta/column-meta/pallet/common/idlist/copytable/offsetmap/relationship - section.go per-section iteration + TactKeyLookup!=0 SKIP path (see §7 C1) - row.go DBD-driven field→meta mapping, id-field-offset, trailing-relation refID, sign/float32 reinterpret - hotfix.go Phase D only: XFTH v9 parse + SStrHash + PushId-ordered overlay - tact/ CASC/TACT local read path -- MIT (derivative of TACTSharp); Phase D helpers also cite wow.tools.local - buildinfo.go parse .build.info; select entry by Product; Version.Split('.')[3] → buildNumber - config.go build/CDN config key=value parse (values are `ckey [ekey]`; skip the ~318 `vfs-*` TVFS lines — unused, §10 Q4) - cascidx.go local .idx v7 (bucket XOR + packed archive/offset bits) - dataarchive.go data.NNN via os.ReadAt (no mmap) + 30-byte frame skip - encoding.go EN table (paged BE binary search + 40-bit sizes) - root.go TSFM/MFST WoW root (root CKey -> EKey via encoding); post-10.1.7 dfVersion 1/2; enUS locale - blte.go BLTE N/Z decode (stdlib zlib); F unimplemented (never hit); E chunks left zero-filled (skipped, §7 C1) - (keys.go) OPTIONAL, not in v1: only if you later decrypt pre-release content (Salsa20 + local WoW.txt) - fdid.go static name→FDID map (primary) + optional Jenkins96 + listfile.csv fallback - cdn.go Phase C only: versions/cdns, ranged archive GET, group/file .index - sqlite/ output writer -- original code (repo MIT, no attribution owed) - schema.go SQLiteDbCreator port (deletes any pre-existing output DB first — SQLiteDbCreator.cs:11, §5 lifecycle; - type map, PK, FK IX_ index, array TEXT + [Col_i] VIRTUAL) - insert.go SqliteDataInserter port (upsert, idx_ on relation cols in settings order, JSON arrays; NO relation-0->NULL — that C# path is dead code, §5.4) - internal/golden/ validation harness (schema comparer, per-table row dumper) — see §8 -``` - -**Boundary intent:** `dbd` knows no binary formats; `wdc` depends on `dbd` (types drive decode) but not `tact`; `tact` yields raw bytes and knows no DB2 semantics; `sqlite` consumes decoded rows + DBD metadata. `main.go` is the only meeting point. - -Do **not** merge into `tools/database/dbc` — that package is a *consumer* of `wowsims.db`, not a decoder; there is nothing to share today. A future direct-to-`dbc` refactor is out of scope. - -### 3.2 Reuse-vs-port decision table - -| Component | Decision | Chosen Go lib / port source (license) | Minimal surface covered | -|---|---|---|---| -| SQLite writer | **Reuse** | `modernc.org/sqlite` v1.37.0 (BSD-3, already a dep, no cgo) + stdlib `encoding/json` | schema DDL + upsert insert only | -| `.dbd` parser | **Port** | from WoWDBDefs `code/C#/DBDefsLib` (**BSD-3**) | COLUMNS block + version blocks; exact-build select; 4 types {int,float,locstring,string} + dead-but-keep `uint`; throw on unknown | -| WDC5 record decoder | **Port** | from `wowdev/DBCD` `WDC5Reader`/`BitReader` (**MIT**); `jonathanherbst/model_export` `db2.go` (MIT) as algorithm oracle; `Frostshake/WDBReader` (MIT, C++) cross-check | WDC5 only (all 72 files are WDC5); 6 compression modes; multi-section; sparse/offset-map; copy-table; relationship; **encrypted-section skip** | -| DBCD storage/`Load` | **Port (thin)** | from `wowdev/DBCD` (**MIT**) | `Load` + `row[col]` + `Values` + array materialization; no writers/enums/locale-array/encryption-key | -| Hotfixes | **Defer (no-op v1)** | Phase D: `wowdev/DBCD` `HTFXReader` (MIT) + `wow.tools.local` `DBCacheParser`/`HotfixManager` (verify license) | XFTH v9 + SStrHash + PushId-ordered overlay — only if a concrete gap appears | -| CASC/TACT local read | **Port** | from `wowdev/TACTSharp` (**MIT**); `ladislav-zezula/CascLib` (MIT, C) as `.idx` reference; `erorus/casc` (MIT, PHP) cross-check | `.build.info`, config, `.idx` v7, `data.NNN`, encoding, TSFM root, BLTE N/Z; **ignore TVFS** (`vfs-*` entries are in the build config but unused — §10 Q4), no InstallInstance, no GroupIndex.Generate | -| BLTE | **Port** (part of `tact`) | stdlib `compress/zlib` | N + Z (the only modes needed); F never hit; **no LZ4 mode exists — do not add pierrec/lz4**. 'E' appears only in skipped encrypted sections (§7 C1) — left zero-filled, not decoded in v1 | -| CDN/Ribbit fallback | **Defer (Phase C)** | stdlib `net/http` | optional; only for install-free builds | -| listfile FDID resolution | **Reuse file, replace mechanism** | static `name→FDID` map (primary); Jenkins96 + `listfile.csv` fallback | 80 fixed paths/FDIDs (72 `dbfilesclient/*.db2` + 8 `gametables/*.txt`; 79 unique *names* since `SpellScaling` appears as both); FDIDs stable per path | -| TACT keys + 'E' decrypt | **Skip (not in v1); optional later** | `golang.org/x/crypto/salsa20` + a `WoW.txt` (wowdev/TACTKeys) if ever enabled | The current tool uses no keys and skips every encrypted section (§7 C1); v1 matches that. Build this only if you later want pre-release content — it would add currently-skipped rows and thus **change** output vs today's golden | - -**No importable pure-Go option exists** for CASC/TACT or WDC5. Rejected candidates: `superp00t/gophercraft` (GPL-3.0, non-compiling stub), `lukegb/snowstorm` (cgo, WoD-era root), `jybp/casc` (no LICENSE file → cannot vendor; no WoW root, no WDC), `gtker/wow_dbc` (Rust, classic WDBC only), `erorus/db2` (PHP, tops at WDC3). All are reference-only. - ---- - -## 4. Licensing & attribution - -Ground truth (already confirmed against upstream LICENSE files): - -| Upstream | License | Obligation on our ported files | -|---|---|---| -| TACTSharp | MIT | `tools/db2tool/tact/*.go` carry an upstream MIT copyright/attribution notice header. | -| DBCD + DBCD.IO | MIT | `tools/db2tool/wdc/*.go` carry an upstream MIT copyright/attribution notice header. | -| WoWDBDefs **code** (DBDefsLib, the `.dbd` parser) | **BSD-3-Clause** | A Go translation is a derivative work: `tools/db2tool/dbd/*.go` carry a **BSD-3-Clause** notice + copyright + the non-endorsement clause, and **stay BSD-3** (not relicensed to MIT). BSD-3 is compatible with the MIT repo. | -| WoWDBDefs **data** (`.dbd` files) | **CC BY-SA 4.0** | **Do NOT vendor into the MIT tree.** Keep fetching at build time (see below). | -| wow.tools.local (`DBCacheParser`, `HotfixManager`, SStrHash S-box) | verify before copying | Phase D only; carry upstream notice if ported. | -| **`WoW.txt` TACT keys** (wowdev/TACTKeys) — **only relevant if you opt into decryption (not in v1)** | **NONE (no `LICENSE`; GitHub license API 404 → all-rights-reserved by default)** | The current tool uses no keys, so this is moot for v1. If decryption is ever added: **do NOT vendor** — supply/fetch `WoW.txt` at runtime and resolve the redistribution question (§10 Q11) first. Keys are hex facts, but the repo grants no license. | -| `modernc.org/sqlite` (replaces Microsoft.Data.Sqlite) | BSD-3 | already a dep; no new obligation. | - -Model export / WDBReader oracles are MIT; if a specific algorithm is cross-checked against them, add a one-line note in the relevant `wdc/*.go` header. Pin the exact upstream commit for any oracle used. - -**`.dbd` data handling decision (recommended):** mirror `GithubDBDProvider` — fetch `https://raw.githubusercontent.com/wowdev/WoWDBDefs/master/definitions/

.dbd` at build time, cache under a **gitignored** `DBDCache/` with the 24h-mtime rule. This matches the current clean state (verified: `DBDCache/*.dbd` and `listfile.csv` are already gitignored). The extracted game facts that flow into `wowsims.db` are *not* bound by share-alike; the `.dbd` files themselves are. **Fallback if offline/CI reproducibility ever forces vendoring:** isolate the ~72 `.dbd` under a clearly-attributed directory retaining CC BY-SA 4.0 + share-alike notice, do not relicense — a separate, reviewed decision, not part of this port. - -**Concrete NOTICE plan (decide once, before coding, to avoid per-file drift):** - -1. Add `tools/db2tool/NOTICES.md` (or `THIRD_PARTY_NOTICES`) listing each upstream (URL, license, pinned commit) and which package directory derives from it. -2. Standardize a 3–5 line header block per license (one for MIT-attribution, one for BSD-3-with-non-endorsement). Every new file in `dbd/`, `wdc/`, `tact/` opens with the correct block. -3. `sqlite/` and `config/` are original repo code (the schema/insert rules are facts, not a translation) — standard repo MIT, no attribution header needed. -4. Keep `.dbd` fetched-not-committed and `listfile.csv` gitignored, unchanged from today. - ---- - -## 5. The output / schema contract to preserve - -Every rule below is byte-exact-critical for the tables the consumer reads (§5.6); the ~11 "slack" tables must merely extract without error. - -**Run lifecycle (easy to miss — it lives in the helper, not Program.cs):** `CreateDatabaseWithDefinitions` first **deletes the output file if it exists** (`SQLiteDbCreator.cs:11`) — the port must recreate `wowsims.db` from scratch on every run. `CREATE TABLE IF NOT EXISTS` (§5.3) and the upsert (§5.4) therefore only ever see a fresh file — keep the `IF NOT EXISTS` text verbatim anyway, because the §8 step-3 schema gate diffs `sqlite_master` DDL, which contains it. Delete-first is what makes post-patch re-runs and `make db`/`make ptrdb` alternation (shared `CLIENTDATA_OUTPUT`, different builds — §1) correct: without it, upserts never delete removed rows and `IF NOT EXISTS` silently keeps a stale build's schema (e.g. the build-suffixed `Field_*` column names, §5.5). - -### 5.1 Version-definition selection (drives the whole schema) - -Per table: `versionDef = the LAST versionDefinition in file order whose Builds contains a Build with build == buildNumber`, where `buildNumber = uint(Version.Split('.')[3])` **from the live install** (`68571` at analysis time, but it changes on every game patch — §1). **Exact equality**, `builds` only — `buildRanges` and `layoutHashes` are *not* consulted. This must be replicated bug-for-bug (see §7 H1 / §5.5). Because the build moves, the matching `.dbd` for the *current* build must already exist in WoWDBDefs before a run; if not, fail loud (do not fall back to a nearby build — §5.5). - -**Scope caveat — this is the SQLite helpers' rule, and the C# tool actually has *two* selection rules.** The builds-only, trailing-build-number, LAST-match rule above is what `SQLiteDbCreator.cs:35` / `SqliteDataInserter.cs:13` use. The row-decode half (`DBCD.Load`, Program.cs:84) uses a different one: DBDefsLib's `GetVersionDefinitionByBuild` takes the **FIRST** block whose `builds` contains the **full 4-part** version (`5.5.4.68571`) *or* whose `buildRanges` contains it, with a **layoutHash fallback** when no build matches. Today both rules resolve to the same block for every table (verified against all 72 cached `.dbd`: no build listed in two blocks; ranges exist up through 5.4.8 but none contains 5.5.4.68571), so the port's single exact-build selector (`select.go`) is a *deliberate simplification of two C# rules*, not a transcription of one. Keep the fail-loud error when the exact build is absent — that is precisely the case where the C# halves diverge (decode would succeed via range/layoutHash while the helpers NRE on a default-struct `versionDef` at `SQLiteDbCreator.cs:40`). - -### 5.2 Scalar columns (`arrLength == 0`) - -- Type map: `int`/`uint` → `INTEGER`; `float` → `REAL`; `string`/`locstring` → `TEXT`. **Throw on anything else** (matches `MapToSqLiteType`). -- `[Name] `. Append ` NULL` iff the column has both `foreignTable` and `foreignColumn` set **and** is not the ID column. Append ` PRIMARY KEY` iff `isID`. -- If FK (both foreign fields set): also emit `CREATE INDEX IF NOT EXISTS IX_
_ ON [
] ([])`. -- `locstring` maps to a single `_lang` TEXT column named verbatim from the DBD (`Display_lang`, `Name_lang`, `HordeName_lang`, …). **No suffix synthesis, no locale array** — this is settled by the DBD, not open. - -### 5.3 Array columns (`arrLength > 0`) - -- One real column `[Name] TEXT` holding a JSON array. -- Plus, for `i` in `0..arrLength-1`, a generated column: - `[Name_i] GENERATED ALWAYS AS (json_extract([Name], '$[i]')) VIRTUAL` - where `` is the type map applied to the base type. -- `CREATE TABLE IF NOT EXISTS [
] (...)`; then FK index statements. `PRAGMA foreign_keys = ON;` is set for parity but no `FOREIGN KEY` constraints are emitted — only indexes. - -### 5.4 Inserts - -- Preserve `versionDef.definitions` column order. -- Upsert: `INSERT INTO [
] ([c1],...) VALUES (@c1,...) ON CONFLICT([pk]) DO UPDATE SET [c]=excluded.[c] ...` for every non-PK column; if only the PK exists → `DO NOTHING`. -- For each `isRelation` column: `CREATE INDEX IF NOT EXISTS idx_ ON
();`. **The index name omits the table**, so with `IF NOT EXISTS` only the *first* table processed with a given relation-column name gets the index (reference DB has exactly 11 `idx_*`; e.g. `idx_spellid` lands on `SpellMisc` only, by settings order). The port must create indexes in settings-`Tables[]` order with the same table-less names — and must **not** iterate tables via a Go map anywhere (also §7 M3). -- Per row: `NULL` for missing values; array values JSON-serialized as `[a,b,c]`. **Do NOT convert relation-0 to NULL:** the C# `if (colDef.isRelation && value == (object)0)` (`SqliteDataInserter.cs:78`) is a *boxed reference comparison* that is always false — dead code. The reference DB keeps 0s (e.g. `ItemSubClass.ClassID` has 9 zero rows, no NULLs), so the port must insert 0 as 0. Replicating the *apparent* intent would break row parity on `ItemSubClass` / `ItemUpgrade` / `ItemNameDescription` (all critical). - -### 5.5 Array-serialization hazards the consumer enforces (verified in `tools/database/utils.go`) - -- `parseIntArrayField` (`utils.go:15`) returns `nil,nil` on `""`, else unmarshals and **errors unless `len == expectedLen`**. Used on many base TEXT columns (non-exhaustive): `EffectMiscValue`(2), `EffectSpellClassMask`(4), `ImplicitTarget`(2), `ItemSparse.StatModifier_bonusStat`→`BonusStat`(10) / `SocketType`→`Sockets`(3), `SpellItemEnchantment` triplets, and `Spell*` masks incl. a 17-length `Attributes`. -- `parseFloatArrayField` (`utils.go:29`) has **no empty-string guard** — `""` → error. Used on `StatPercentageOfSocket`(10), `Field_1_15_3_55112_014`→`StatAlloc`(10), `StatModifier_bonusAmount`→`BonusAmountCalculated`(10), `StatPercentEditor`→`SocketModifier`(10), `ItemDamage*.Quality`(7). - -**Consequence:** array columns must serialize as a JSON array with **exactly `arrLength` elements**, matching what C# `JsonSerializer.Serialize` emits for an all-zero/empty array (i.e. `[0,0,...]`, not `NULL`/`[]`/`""`). Confirm the C# emission and match it byte-for-byte, or `make db` dies in the consumer. - -**Float precision (load-bearing):** DBD `type=="float"` fields must be decoded and marshaled as **`float32`**, not `float64`. Go's `encoding/json` prints 32-bit-precise text only for a `float32` value; an upcast makes `0.1f` become `0.10000000149011612` in both the JSON text and the `json_extract` REAL column — silently wrong item stats. **Scalar** float REAL columns differ but come out right: binding a `float32` stores the double-widened value (verified: `SpellProcsPerMinute.BaseProcRate` = `0.5809999704360962`), which `database/sql` reproduces automatically — so bind scalars as `float32` and let widening happen; don't format either side as text. - -**Float *notation* divergence (verified — `float32` + raw `json.Marshal` is NOT full text parity):** .NET's shortest-round-trip formatter switches to scientific notation (uppercase `E`, two-digit zero-padded exponent) for `|v| < 1e-4` or `>= 1e15`; Go's `encoding/json` does so only for `|v| < 1e-6` or `>= 1e21` (lowercase `e`, unpadded). Already observable in real data: `CurvePoint` Id=236585 stores `Pos = "[1,-6E-05]"` (reference) where Go emits `"[1,-0.00006]"` — the only E-notation values in the entire DB today, and `CurvePoint` is slack (§5.6) with no reader under `tools/database`. **Resolution:** keep raw `json.Marshal`, scope array-text byte parity to the critical set (§5.6, §8 step 4), and add a harness assertion that no *critical*-table float array element falls in the divergent ranges `[1e-6,1e-4)` / `[1e15,1e21)` — if that assertion ever fires, implement a small .NET-compatible float32-to-text formatter for JSON array elements instead. **String-array escaping (future-proofing):** no string/locstring *arrays* exist in the 68571 blocks, but if one ever appears, C# `JsonSerializer` escapes non-ASCII/HTML as uppercase `<` while Go emits lowercase `<` / raw UTF-8 — a byte-parity trap to handle then. - -**Schema shape is build-dependent, not just values:** `ItemRandomSuffix.dbd` defines `AllocationPct` as `<32>[3]` in one build block and `[5]`/`<32>[5]` in others. The consumer reads `AllocationPct_0..AllocationPct_4` (5 virtual columns); a wrong version block yields `arrLength=3` → columns `AllocationPct_3/_4` don't exist → `tables.go` fails "no such column". This is why exact-build match must be replicated and why "nearest ≤ build" is unsafe. On no match, fail loud (not nil-panic). - -### 5.6 Byte-exact-critical vs slack tables - -**Critical (row + value parity required):** `Item`, `ItemSparse`, `SpellEffect`, `SpellItemEnchantment`, `ItemRandomSuffix`, `RandPropPoints`, `SpellMisc`, all 8 `ItemDamage*`, `ItemArmorQuality/Shield/Total`, `ArmorLocation`, `GemProperties`, `ItemEffect`, `ItemClass`, `ItemSubClass`, `ItemSet`, `ItemNameDescription`, `RulesetItemUpgrade`, `ItemUpgrade`, `Spell` + the large joined set (`SpellName`, `SpellLevels`, `SpellCooldowns`, `SpellScaling`, `SpellLabel`, `SpellCategories`, `SpellCategory`, `SpellDuration`, `SpellPower`, `SpellInterrupts`, `SpellEquippedItems`, `SpellAuraOptions`, `SpellClassOptions`, `SpellShapeshift`, `SpellXDescriptionVariables`, `SpellDescriptionVariables`, `SpellTargetRestrictions`, `SpellRange`, `SpellRadius`, `SpellProcsPerMinute`, `SpellProcsPerMinuteMod`), `GlyphProperties`, `SkillLineAbility`, `Talent`, `Faction`, `Map`, `JournalEncounter/EncounterItem/Instance`, `AreaTable`. - -**Slack (extract without error; schema/values not in today's acceptance test):** `ItemSetSpell`, `ItemSubClassMask`, `ItemReforge`, `ItemBonus`, `ItemRandomProperties`, `ItemExtendedCost`, `Curve`, `CurvePoint`, `ScalingStatDistribution`, `SpellReagents`, `SpellMechanic` (and, subject to grep-caveat, `Difficulty`, `TalentTab`, `SkillLine`). - -### 5.7 The other two outputs - -- **8 basestats `.txt`** written verbatim; **filename casing preserved from settings** (`chancetomeleecrit`, `chancetomeleecritbase`, `chancetospellcrit`, `chancetospellcritbase`, `combatratings`, `octbasempbyclass`, `OCTBaseHPByClass`, `SpellScaling`). `SpellScaling.txt` additionally exists as a committed `//go:embed` at `tools/database/dbc/GameTables/SpellScaling.txt` — independent of the run; document that it is not auto-synced. -- **`listfile.csv`** must still exist at the path the consumer expects (see §9 for the repoint decision). -- **`item_enchantment_template`** is created by `tools/database/overrides/{0,1}.sql` (`RunOverrides`, `dbhelper.go:63`), **not** by DB2 — the port must not fold it in. - ---- - -## 6. Phased implementation plan - -Each phase ends with a concrete golden-diff gate (§8) before the next begins. The validation assets are two references re-captured per build by today's dotnet tool on a WoW-installed machine — `wowsims.nohotfix.db` (gates A/B) and `wowsims.hotfix.db` (gates D) — plus the `dbfilesclient/*.db2` and `DBDCache/*.dbd` it drops (§8 step 1, §10 Q5). - -### Phase A — `.dbd` parser + WDC5 decoder + SQLite writer (fed by pre-extracted `.db2`) - -**Scope.** Everything downstream of file extraction, decoupled from CASC: -- `dbd`: full parser + exact-build `versionDef` selection (§5.1), complete data model (incl. `size`/`isSigned`/`isNonInline` for the reader). Strip UTF-8 BOM; handle CRLF; hard-error on a version-field name absent from COLUMNS. -- `wdc`: WDC5 reader — header (magic assert `"WDC5"`; fail loud on WDC6+), **multi-section iteration** (only 51/72 are single-section; core tables have 26–36), all 6 compression modes (None / Immediate / SignedImmediate / Common / Pallet / PalletArray), id-list, copy-table, sparse/offset-map inline strings (**`Spell` and `ItemSparse` are both `Flags=0x5` = Sparse(0x1)|Index(0x4)**, i.e. sparse + non-inline id list; per DBCD `DB2Flags`, `SecondaryKey` (0x2) is *not* set on any table here), relationship/parent-lookup trailing FK columns, negative-base string-offset resolution, and the **encrypted-section skip path** (§7 C1). No hotfixes. -- `sqlite`: schema creator + inserter (§5.2–§5.5), incl. `float32` marshaling and strict-length arrays. -- Temporary driver: read `.db2` from `tools/DB2ToSqlite/dbfilesclient/` and `.dbd` from `DBDCache/`; `buildNumber` passed as a flag. - -**Key tasks:** bit reader (byte-exact unaligned LE load + shift pair); section skip + copy-of-skipped-source drop; DBD field→meta index mapping (id-field-offset, `fieldIndex >= Meta.Length` → refID); JSON array shaping to match C#. - -**Exit criteria.** -1. **Schema parity:** all 72 tables — `sqlite_master` DDL (whitespace-normalized) identical to reference, including PK / `NULL` / `IX_*` / `idx_*` / `[Col_i]` VIRTUAL count and DBD-derived names (`Field_1_15_3_55112_014`, `Field_1_15_7_59706_054`). -2. **Row parity:** every critical table (§5.6) — `SELECT * ORDER BY ` canonical dump equals reference; **row counts checked first** as a cheap tripwire (encrypted-skip makes this non-trivial — see §7 C1). Slack tables must extract without error only. -3. **End-to-end:** `go run tools/database/gen_db/*.go -outDir=./assets -gen=db` against the Go DB yields **byte-identical** `assets/database/db.json` / `leftover_db.json` vs the reference DB (control ordering per §7/§8; run gen_db against *copies* — it mutates its input via `RunOverrides`, §8 step 1). This is the true acceptance test. - -**Effort: L** (dbd = S, sqlite = S, WDC5 decoder = L and dominates; multi-section + sparse + encrypted-skip on the core tables is the critical path). -**Dependencies:** none (uses pre-extracted artifacts). Proves the correctness core with zero CASC code. - -### Phase B — Local CASC/TACT extraction (a deliberate behavior change: the current tool is CDN-fed, §2.1 step 5) - -**Scope.** Replace the pre-extracted-`.db2` driver with real extraction from a local install. Note this is *new* behavior, not a transcription — the .NET tool fetches everything from the CDN (revision note, §1 Stance); the local path is chosen because it is simpler (no HTTP, no group-index generation, no 1.2 GB cache) and because gate 1 gives an exact oracle. Components: -- `tact`: `.build.info` parse + entry-by-Product + `Version.Split('.')[3]`→`buildNumber`; build/CDN config parse; local `.idx` v7 (bucket XOR, packed offset/archive bits, +30 frame); `data.NNN` via `os.ReadAt`; EN encoding table; TSFM root (post-10.1.7 dfVersion 1/2, enUS); **BLTE N/Z only** (encrypted 'E' chunks stay zero-filled, so the WDC layer skips those sections — matching the keyless .NET tool; §7 C1). No Salsa20 / `WoW.txt` in v1. -- `OpenFileByFDID`: FDID → root CKey → encoding EKey → local `.idx` → `data.NNN` → BLTE. -- FDID: static `name→FDID` map (primary) + optional Jenkins96 + `listfile.csv` fallback. -- GameTables extraction: open `gametables/.txt` by FDID, write raw bytes to `GameTablesOutDirectory`, **preserving filename casing**. -- **`listfile.csv` contract fix** (§9): repoint the three hardcoded literals in the same change. -- **CWD/relative-path fix** (§7 M4): resolve `GameTablesOutDirectory`, `TargetDirectory`, `DBDCache/`, `listfile.csv` relative to the settings file (or repo root), not the process CWD. -- **HTTP fetchers (the current tool does these every run — they are NOT CASC/CDN and belong here, not Phase C):** (a) fetch each `.dbd` from `https://raw.githubusercontent.com/wowdev/WoWDBDefs/master/definitions/
.dbd` into the gitignored `DBDCache/` with a 24h-mtime refresh (mirrors `GithubDBDProvider`); (b) fetch/refresh `listfile.csv` from `ListfileURL` (HEAD + Last-Modified, honoring `ListfileFallback`). Without these, a fresh machine — or any machine after a game patch (new-build `.dbd` mandatory per H1; listfile gains new FDIDs) — cannot run `make db`. -- Wire `makefile` (§9). - -**Exit criteria.** -1. On a machine with a real `wow_classic` install, the tool produces a `wowsims.db` passing all Phase A criteria; first gate is a per-table diff of the extracted `.db2` bytes vs the vendored `dbfilesclient/*.db2` (a free exact oracle). -2. 8 basestats `.txt` byte-identical to the committed files. -3. `make db` runs end-to-end **with no dotnet installed** → byte-identical `db.json` / `leftover_db.json`. -4. Fail-loud (not nil-panic) when: a table's build isn't in the `.dbd`; `BaseDir` is missing; a needed FDID isn't in the local `.idx`. - -**Effort: L** (~6 byte-exact parsers; local-first avoids CDN). -**Dependencies:** Phase A. **Pre-flight:** the live install's local CASC files are verified *present and well-formed* (`5.5.4.68571`): the build config has a usable WoW `root` (TVFS coexists but is unused — skip `vfs-*` lines, §10 Q4); 16 `.idx` buckets (v7 confirmed: 9-byte keys, 30-bit offsets), 27 `data.NNN` (~1 GB each), `config/` (build+cdn), prebuilt `indices/*.index`; Install Key + KeyRing are empty in `.build.info` (no InstallInstance, no keyring). **But the current tool never reads any of it** (§2.1 step 5), so the load-bearing precondition — *every needed FDID resolves through the local `.idx` to a resident `data.NNN` chunk* (only the unencrypted section 0 matters; the rest are skipped encrypted sections, §7 C1) — is **unproven**. Prove it before writing Go: apply the one-line dotnet patch (`buildInstance.Settings.BaseDir = settings.BaseDir;` after Program.cs:41), run the tool, and byte-diff the extracted `.db2`/gametables against a CDN-fed run's (§1 Stance). That also pins the exact TSFM `dfVersion` question (§10 Q4) against the local root. In the ported local path, `GroupIndex.Generate` and InstallInstance are never needed — fail loud if hit (the as-is CDN tool *does* generate group indices; that code is not ported). Encrypted DB2 sections are BLTE-'E' chunks; leaving them zero-filled (no keys) reproduces today's output (§7 C1), so v1 needs no key handling. - -### Phase C — CDN/Ribbit fallback (optional; only if install-free builds are wanted) - -**Scope.** `tact/cdn.go`: patch-service `versions`/`cdns`, host selection, config-by-hash fetch, ranged archive GET, group/file `.index` footer/TOC binary search. (The `.dbd` and `listfile.csv` HTTP fetches live in Phase B, not here — they are plain GETs the tool always needs, independent of CASC/CDN.) - -**Exit criteria.** With `BaseDir` empty/incomplete, the tool downloads missing pieces and still produces a Phase-A-passing DB; `make db` succeeds with no WoW install. - -**Effort: L–XL** (largest networking surface, incl. `GroupIndex.Generate` — the as-is tool builds four ~120 MB group indices). **Explicitly optional, but note the framing:** the CDN path is what the *current* tool actually uses (§2.1 step 5), so porting it is the strict-parity route; it is deferred anyway because the local path is smaller and Phase B gate 1 proves byte equivalence. Pursue Phase C only for CI/install-free builds — or promote it if the Phase B pre-flight ever shows local extraction incomplete. -**Dependencies:** Phase B. - -### Phase D — Hotfixes (REQUIRED for parity with the committed `db.json`) - -**Scope.** `wdc/hotfix.go`: XFTH v9 header + 28-byte entry parse (the entry follows a 4-byte per-record `XFTH` magic); SStrHash (verbatim 16-entry S-box) `name→tableHash`; byte-aligned sequential blob decode driven by the same DBD field metadata + non-inline-ID rule; `CombineCache` dedup; add/delete overlay (verified against DBCD.IO 2.1.2: `ReadHotfixes` applies records in an explicit stable ascending-PushId sort — LINQ `orderby x.PushId`, file/combine insertion order preserved within a PushId. Row ops via `DefaultProcessor`: Add iff `IsValid && DataSize > 0`, else Delete when `shouldDelete`, else Ignore; `shouldDelete` is false only for tableHash `0xDF2F53CF` (TactKey) and `0x021826BB` (BroadcastText) while a valid PushId == -1 record with data exists — neither table is in our `Tables[]`, so for this port `shouldDelete` is always true and the rule collapses to the plain add/delete overlay. `Combine` dedups via `HashSet` whose `GetHashCode` also hashes the record's *data bytes* — port dedup as full-record identity (PushId, TableHash, RecordId, IsValid, DataSize, **plus data bytes**), not the 5-tuple alone); exact `buildNumber` match. `HotfixManager.LoadCaches` scans `caches/*.bin` + `/**/DBCache.bin`. `knownPushIDs.json` (untracked, written by `HotfixManager`) is logging-only — **do not reproduce it**. In Phases A–C this is a **no-op stub**, but it is **not optional overall**: the maintainer confirmed the committed `db.json` is generated **with** hotfixes applied from the local client's `DBCache.bin`. To reproduce the shipped artifacts, the port must apply them too. - -**Exit criteria.** A run *with* the same `DBCache.bin` matches the committed (with-hotfix) `db.json`; a run *without* matches the un-hotfixed reference used to gate Phases A/B (§8). Also quantify which sim-read fields hotfixes actually touch, to bound risk. - -**Effort: M** (S for hotfix-specific code; rides the Phase A field-metadata layer). -**Dependencies:** Phase A (field metadata). **Sequencing note:** because the end-to-end golden gate (§8 step 6) diffs the *committed, with-hotfix* `db.json`, that gate cannot fully pass until D lands — Phases A/B must gate against a freshly-regenerated *without-hotfix* reference instead (§8). - -**Net recommendation:** execute **A → B → D** (D is required for committed-artifact parity, though it can land last since A/B gate against a without-hotfix reference); treat **C** as opt-in. That removes dotnet from `make db`/`make ptrdb` for the real workflow while proving correctness at every step. - ---- - -## 7. Risks & blockers - -Risk IDs are historical labels kept stable for cross-references; the **Sev** column is authoritative (C1 was downgraded to High and M5 to Low after verification; M6 was upgraded and renamed H4). - -| ID | Sev | Risk | Mitigation | -|---|---|---|---| -| C1 | High | **Encrypted DB2 sections are skipped — the tool uses NO TACT keys (VERIFIED against the vendored `.db2`).** Most core tables have 35 encrypted sections of 36 (`TactKeyLookup != 0` — `Item`/`Spell`/`SpellEffect`/`SpellMisc`/`ItemEffect`/`SpellName`; `ItemSparse` is 25 of 26; section 0 is always the sole unencrypted section), and in the extracted files **all encrypted sections are zero-filled** → DBCD skips them. This is a small amount of pre-release content: per-section counts are tiny (mostly 1–41). Exact check: `SpellEffect` header `record_count` 142756 − 136 encrypted rows = **142620, the DB row count**. The committed `db.json` is this keyless-skip result. Consequences: header `record_count` ≠ emitted rows; copy-table entries whose source is in a skipped section are dropped. | Port DBCD's exact skip test — `TactKeyLookup != 0` **and** the section's record-data all-zero (WDC5Reader also guards first id-list value 0 / first sparse-entry size 0) → skip the section and its copies — a small, well-defined path, **no crypto**. Never derive expected row count from header `record_count`. Golden-diff row counts table-by-table (§8). Decryption is an optional future enhancement (§3.2/§4) that *changes* output — out of scope for a parity port. | -| C2 | **Critical** | **WDC5 decoder correctness.** No importable pure-Go reader; must be byte-exact across ~50 critical tables. Multi-section (26–36 on core tables), sparse offset-map (`Spell` + `ItemSparse` both `Flags=0x5`), 6 compression modes, trailing non-inline relations, negative-base string offsets, and **sign/float32 reinterpretation** each silently corrupt on a single off-by-one. | Golden-diff every critical table vs reference (Phase A). DBCD `WDC5Reader`/`BitReader` as exact spec; `model_export/db2.go` as oracle. WDC5-only (no version matrix). Tolerate 0-section/0-record files (`ItemBonus.db2` is empty). | -| C3 | **Critical** | **Local CASC/TACT read path.** ~6 byte-exact parsers (`.idx` bucket XOR + packed bits, `data.NNN` 30-byte frame, EN 40-bit BE sizes, TSFM dfVersion 1/2 root, BLTE N/Z); any failure yields empty/garbage with no crash. The live install's files are verified *present and well-formed* — `.idx` v7 (9-byte keys, 30-bit offsets), 16 buckets, 27 `data.NNN`, build+cdn configs, prebuilt `indices/`; `.build.info` Install Key + KeyRing empty — **but the current tool never reads them** (§2.1 step 5): the local path is *new behavior*, and its precondition (every needed FDID resident in local archives) is unproven until the Phase B pre-flight. | Prove residency first via the one-line dotnet BaseDir patch + byte-diff (§6 Phase B pre-flight); then diff Go-extracted `.db2` bytes vs vendored `dbfilesclient/*.db2` (free exact oracle). stdlib primitives; `os.ReadAt` not mmap. Skip `vfs-*` config lines (TVFS present but unused, §10 Q4). In the *ported local path*, fail loud if `GroupIndex.Generate` / InstallInstance are ever hit (the as-is CDN tool does run `GroupIndex.Generate` — that code is not ported). | -| H1 | High | **Exact-build `.dbd` match fragility — a recurring operational reality, not a one-off (CORRECTED — the build is not pinned; the tool tracks the live game).** `versionDefinitions.LastOrDefault(v => v.builds.Any(b => b.build == ))`; every game patch changes the build, so each run depends on WoWDBDefs *already* containing that build. Schema (incl. `Field_*` names, `AllocationPct` arrLength) derives from that block; relaxing the rule silently changes the contract (§5.5). | Replicate exact-match bug-for-bug; emit a **clear error** ("build N not in WoWDBDefs yet — wait for upstream"), not a nil-panic, on no match. Refresh `.dbd` per run. Any range fallback is a separate, reviewed change. | -| H2 | High | **`listfile.csv` second contract.** Hardcoded at `gen_db/main.go:153`, `gen_protos.go:458`, `tables.go:1123`; 148 MB; also needed for extractor FDID lookup. Easy to overlook since it's not the `.db`. | Repoint all three literals in Phase B (§9). Static FDID map removes the download *from extraction*, but the icon map still needs the full listfile — keep producing/caching it. | -| H3 | High | **Float32 precision** (§5.5). `float64` marshaling silently corrupts item stats in `db.json`. | Decode/marshal DBD `float` as `float32`; spot-check anchored IDs incl. float values (§8). | -| M1 | Med | **CI without a WoW install.** `make db` needs a ~100 GB local install; no CI runner has it. *Not a regression* — the dotnet tool has the same constraint and outputs are committed, so CI never runs `make db`. Consequence: the port is validated on a maintainer machine, not CI. | Keep `make db` maintainer-run. Phase A validates offline against vendored `.db2`; Phase B against vendored `.db2` outputs. | -| M2 | Med | **Byte-diffing the `.db` file fails spuriously.** Microsoft.Data.Sqlite vs modernc differ in SQLite version, page size, journal/encoding PRAGMAs, rowid/freelist. | Validate *logically* — schema DDL + per-table `ORDER BY pk` dumps + `db.json` text diff (§8). Never MD5 the `.db`. | -| M3 | Med | **Row/section ordering vs the committed `db.json`.** Section iteration / copy-table / Go map order can produce a logically-equal DB but a different (committed, text) `db.json`. | Guarantee deterministic order matching C#, or make the `db.json` gate order-insensitive; don't assume `git diff db.json == 0` without controlling order. | -| M4 | Med | **CWD / relative-path contract.** `make db` does `cd tools/DB2ToSqlite && dotnet run`; `GameTablesOutDirectory="../../assets/db_inputs/basestats"`, `TargetDirectory`, `DBDCache/`, `listfile.csv` all resolve relative to that dir. `go run ./tools/db2tool` from repo root resolves `../../...` above the repo. | Resolve these paths relative to the settings file (or repo root) in `config`/`main.go`; re-pin every relative base deliberately in Phase B. **Carve-out:** `TargetDirectory` is used *twice* in Program.cs (lines 77-78) — resolve only the on-disk output-directory use; any Jenkins96/listfile FDID lookup (the §3.2 fallback) must key on the raw game path `dbfilesclient/NAME.db2` (the unresolved settings value), never a resolved filesystem path — a resolved path hashes to a listfile miss (`GetFDID` → 0) and `OpenFileByFDID` throws "File not found in root". The primary static name→FDID map (§3.2) is immune. | -| M5 | Low | **BLTE 'E' decode is NOT needed for parity (VERIFIED — the tool runs keyless).** Encrypted chunks arrive zero-filled and their sections are skipped; N + Z cover every other chunk. | Implement N + Z only; leave 'E' chunks zero (do not error). F never occurs; ARC4 ('A') not needed. Salsa20 + `WoW.txt` is an optional enhancement (§3.2), deliberately out of v1 parity scope. | -| H4 | High | **Hotfixes are applied in the committed output (CORRECTED — maintainer confirmed `db.json` is generated WITH hotfixes).** DBCache content is machine/time-dependent, but the shipped artifacts include it, so a faithful port must apply hotfixes to reach parity. | Phase D is **required** (not a permanent stub). Gate Phases A/B against a freshly-regenerated *without-hotfix* reference; gate the final committed `db.json` only after D. Quantify which sim-read fields hotfixes touch to bound scope. | -| L1 | Low | **Licensing correctness.** | Per-file headers + `NOTICES.md` (§4); `.dbd` fetched-not-vendored; `listfile.csv` gitignored. `WoW.txt`/TACTKeys (no license) is only a concern if the optional decrypt path is ever built (§4, §10 Q11) — not for v1. | -| L2 | Low | **no-cgo** already satisfied; `modernc.org/sqlite` verified for VIRTUAL cols + `json_extract` + `ON CONFLICT` + `PRAGMA foreign_keys`. | Keep the port cgo-free; no `pierrec/lz4` (no LZ4 mode exists). | - ---- - -## 8. Validation strategy - -Byte-diffing the `.db` will fail spuriously (M2). Validate in a layered ladder; build the harness once (`tools/db2tool/internal/golden/`) and reuse it every phase. - -1. **Capture a reference (re-captured on every game patch/hotfix — it is not permanently frozen; maintainer-owned).** Run today's dotnet tool on the *current* live build; save two references: **`wowsims.nohotfix.db`** (no `DBCache.bin`) to gate Phases A/B, and **`wowsims.hotfix.db`** (with the client's `DBCache.bin`, matching how the committed `db.json` is produced) to gate Phase D. Also keep that run's `dbfilesclient/*.db2` and `DBDCache/*.dbd` so the WDC/DBD layers validate offline, decoupled from CASC. The 72 `.db2` + 72 `.dbd` already present in the repo working copy are exactly such a snapshot. **Producing the `nohotfix` capture:** the dotnet tool has no disable switch (`HotfixManager.LoadCaches` auto-scans `/**/DBCache.bin`), so capture it by temporarily moving/renaming the client's `DBCache.bin` files (or a one-line local patch to skip `LoadCaches`) before that run. **Keep the captured references pristine:** gen_db *mutates* the DB it opens — `RunOverrides` (`dbhelper.go:63`, called at `gen_db/main.go:66`) creates/populates `item_enchantment_template` in it — so always run end-to-end gates against disposable *copies* of both the Go-produced DB and the reference (place the copy at the default `-dbPath ./tools/database/wowsims.db` or pass `-dbPath` explicitly). -2. **Pre-port audit (no code needed; inputs already on disk).** Dump per-table `SectionsCount`, per-section `TactKeyLookup`, `Flags`, and DBD `arrLength`/type for all 72 `.db2`+`.dbd`; freeze as the reader's expectation fixture. (Seed values: the *selected* build-68571 version blocks — what the reader actually sees — total int 515 / float 65 / locstring 43 / string 5, no `uint`. The larger int 786 / float 91 / locstring 50 / string 8 are all-builds COLUMNS totals across the `.dbd`, **not** per-build — don't use those as the fixture. Distinct section counts 36/33/26/22/16/9/8/3/2; `ItemBonus` empty; `Spell`/`ItemSparse` `Flags=0x5`.) -3. **Schema parity.** Compare sorted `sqlite_master` (CREATE TABLE + indexes, whitespace-normalized) reference vs Go. Catches column names/order, PK/NULL, the `[Name] TEXT` + `[Name_i] ... GENERATED ALWAYS AS (json_extract(...)) VIRTUAL` set, `IX_*`/`idx_*`, and — critically — `arrLength`-derived virtual-column counts (`AllocationPct_0..4`, §5.5). -4. **Logical row parity (not byte).** Every table: assert **row counts** first (cheap tripwire, decisive for the encrypted-skip tables in C1). Then `SELECT * ORDER BY ` canonical dump equality for the **critical set (§5.6) only** — matching Phase A exit criterion 2; slack-table text diffs are reported informationally, not gating (known expected divergence: `CurvePoint` Id=236585 float notation, §5.5). Explicitly check that relation columns **keep 0** (the C# 0→NULL is dead code — §5.4; spot-check `ItemSubClass.ClassID = 0`) and the array-JSON text shape (§5.5), incl. the no-divergent-float-magnitude assertion from §5.5. -5. **Spot-check anchored IDs.** Pin known sim-relied IDs that exist in *this* DB and assert exact values incl. float precision: an `ItemSparse` row with its `Field_1_15_3_55112_014` stat array; a spell in `SpellEffect` with `EffectMiscValue`/`EffectSpellClassMask`; an `ItemRandomSuffix` row (e.g. `[6666,10000,0,0,0]`) and an `ItemArmorQuality.Qualitymod` array. (Do **not** use `146051` — verified absent from this MoP DB.) -6. **End-to-end golden (the real test).** Run full `make db` with the Go extractor **against the same live install + `DBCache.bin` the committed artifacts were built from (with hotfixes — §6 Phase D)**, then `git diff --exit-code` on the **committed** artifacts: `assets/database/db.json`, `assets/database/leftover_db.json` (text, diffable), the regenerated `.bin` files, and `assets/db_inputs/basestats/*.txt`. Control ordering (M3) or diff order-insensitively. `db.json` is what ships — this proves the whole contract, since there is **no committed golden `wowsims.db`** (it's gitignored). This gate needs Phase D; before D, gate against the without-hotfix reference (step 1). -7. **modernc smoke test (committed).** A small Go test that creates the §5.2/§5.3 schema shapes, upserts via `@name` params, and reads back `json_extract` virtual columns (int *and* float), NULL scans, and REAL vs INTEGER marshaling — so driver-marshaling parity is a permanent regression gate, not a one-off. - ---- - -## 9. Makefile / dev-workflow changes & dotnet-removal cleanup - -### 9.1 Makefile (targets and var names unchanged) - -Current (`makefile:245-261`): - -```make -CLIENTDATA_SETTINGS := $(shell realpath ./tools/database/generator-settings.json) -CLIENTDATAPTR_SETTINGS := $(shell realpath ./tools/database/ptr-generator-settings.json) -CLIENTDATA_OUTPUT := $(shell realpath ./tools/database/wowsims.db) - -.PHONY: db -db: - @echo "Running DB2ToSqlite for clientdata" - cd tools/DB2ToSqlite && dotnet run -- -s $(CLIENTDATA_SETTINGS) --output $(CLIENTDATA_OUTPUT) - @echo "Running DBC generation tool" - go run tools/database/gen_db/*.go -outDir=./assets -gen=db - -.PHONY: ptrdb -ptrdb: - @echo "Running DB2ToSqlite for clientdata" - cd tools/DB2ToSqlite && dotnet run -- -s $(CLIENTDATAPTR_SETTINGS) --output $(CLIENTDATA_OUTPUT) - @echo "Running DBC generation tool" - go run tools/database/gen_db/*.go -outDir=./assets -gen=db -``` - -Target (keep the `.PHONY` declarations — a repo-root file named `db`/`ptrdb` would otherwise make the targets report up-to-date): - -```make -.PHONY: db -db: - @echo "Extracting client data (pure Go)" - go run ./tools/db2tool -s $(CLIENTDATA_SETTINGS) --output $(CLIENTDATA_OUTPUT) - @echo "Running DBC generation tool" - go run tools/database/gen_db/*.go -outDir=./assets -gen=db - -.PHONY: ptrdb -ptrdb: - @echo "Extracting client data (pure Go)" - go run ./tools/db2tool -s $(CLIENTDATAPTR_SETTINGS) --output $(CLIENTDATA_OUTPUT) - @echo "Running DBC generation tool" - go run tools/database/gen_db/*.go -outDir=./assets -gen=db -``` - -Note the removal of `cd tools/DB2ToSqlite`: the Go tool must resolve `GameTablesOutDirectory` / `TargetDirectory` / `DBDCache` / `listfile.csv` relative to the settings file or repo root (M4), since `go run ./tools/db2tool` executes from repo root — but observe the M4 carve-out: `TargetDirectory`'s *listfile-key* use stays the raw settings value (`dbfilesclient/...`), only its output-directory use is resolved. Keep the `-s` / `--output` flag contract (also accept the single-dash `-output` and `-o` aliases, as Program.cs does); the settings `DatabaseFile` key is **dead code** — Program.cs never reads it (§10 Q7), so don't implement it. - -### 9.2 `listfile.csv` path repoint (do this in Phase B) - -Recommended: write `listfile.csv` to `tools/db2tool/listfile.csv` and repoint the three consumers: - -- `tools/database/gen_db/main.go:153` -- `tools/database/gen_protos.go:458` -- `tools/database/tables.go:1123` - -(Alternative: keep writing to `tools/DB2ToSqlite/listfile.csv` and leave the literals — but that resurrects the deleted directory as a data dir. Repointing is cleaner.) Keep `listfile.csv` gitignored under its new location. - -### 9.3 dotnet-removal cleanup (after Phase B passes) - -- Delete `tools/DB2ToSqlite/references/*.dll` (TACTSharp, DBCD, DBCD.IO, DBDefsLib). -- Delete `tools/DB2ToSqlite/cache/` — TACTSharp's CDN cache (~1.2 GB, `tpr/` + `wow/` layout, gitignored). The Go v1 has no use for it: the local-install path needs no CDN cache (`CacheDir` is bound-but-unused, §3.1); only Phase C would reintroduce one, under `tools/db2tool/`. -- Delete `tools/DB2ToSqlite/*.csproj`, `Program.cs`, `Helpers/`, the copied `DBCacheParser.cs` / `HotfixManager.cs`, `appsettings.json`, `appsettings.Development.json`, `Properties/launchSettings.json`, `.vscode/launch.json`, `knownPushIDs.json`, and the `obj/` / `bin/` build dirs. -- Remove the `DB2ToSqlite` project from the `.sln` (and delete the `.sln` if it has no other projects). -- Preserve or relocate build-artifact dirs still referenced: `dbfilesclient/`, `DBDCache/`, `listfile.csv` move under `tools/db2tool/` (all gitignored). Migrate the relevant `.gitignore` entries. -- Grep the repo and CI/docs for `DB2ToSqlite`, `dotnet`, `.csproj` references and update (README/build docs, any workflow that installs the .NET SDK). -- The 44-table `appsettings.json` subset is dead (both `make` targets pass the 72-table generator configs) — drop it; confirm no other caller (§10 Q). - ---- - -## 10. Open questions to resolve before / while building - -1. **Encrypted-section semantics — RESOLVED (verified against the `.db2`).** The tool uses no keys: all encrypted sections are zero-filled and skipped (§7 C1). v1 replicates the skip; no decryption. Optional future enhancement: enable Salsa20 + `WoW.txt` to pull in pre-release content — this *changes* output, so treat it as a separate feature (§3.2/§4). -2. **Hotfixes in the committed `db.json` — ANSWERED: YES, with hotfixes.** Phase D is therefore required for committed-artifact parity (§6, §7 H4); A/B gate against a without-hotfix reference (§8). -3. **Local-only vs CDN.** Is a complete local install guaranteed on every machine that runs `make db`? Note the stakes changed with revision note (4): the *current* tool is CDN-fed, so today `make db` works even against a partial install — the local-first port raises the bar to "every needed FDID resident locally". Confirm via the Phase B pre-flight (one-line dotnet BaseDir patch + byte-diff) that the local CASC path yields each table's data — in practice its single unencrypted section 0 (the encrypted sections are skipped, §7 C1) — so Phase C stays optional. PTR installs are likelier partial — check `wow_classic_ptr` specifically. `make db` is maintainer-run against a live install, re-run on each patch/hotfix (§1). -4. **Root/manifest variant — VERIFIED (against the live install's build config).** The `wow_classic` build config has a non-zero WoW `root = 8caf1829…` (a CKey — resolve via encoding to an EKey, then read from local CASC), so `OpenFileByFDID` uses the classic WoW root (MFST/TSFM). TVFS **is also present** (`vfs-root` + ~318 `vfs-N`) but is **not used** for FDID lookup — the config parser must *skip* `vfs-*` lines, not choke. (Caveat per revision note (4): the *current* tool resolves this same root via the CDN, not local CASC — the conclusion about which root type `OpenFileByFDID` uses is unaffected.) Remaining: the exact TSFM `dfVersion` (1 vs 2) still needs confirming by decoding the root file — either during the Phase B pre-flight (the BaseDir-patched dotnet run exercises it) or when the Go BLTE/`.idx` path lands. -5. **Reference capture — ANSWERED (partially): the maintainer regenerates it whenever new patches/hotfixes land; it is not a one-time frozen asset.** Formalize: keep a with-hotfix and a without-hotfix capture per build (§8 step 1), and decide where they live (local, not committed). -6. **`listfile.csv` strategy.** Static FDID map primary + Jenkins96/CSV fallback (recommended), and final on-disk location after `tools/DB2ToSqlite/` is deleted (recommended `tools/db2tool/listfile.csv`, repoint 3 literals). The full CSV is still required for the icon map regardless. -7. **`--output` vs settings `DatabaseFile` — ANSWERED: `DatabaseFile` is dead code.** Program.cs never reads the JSON key; the output path is the hardcoded default `wowsims.db` overridden only by `--output` / `-output` / `-o` (note the single-dash `-output` alias). The port should implement the flags and **not** read `DatabaseFile`; both `make` targets always pass `--output`. -8. **Exact-build match: keep bug-for-bug or add a reviewed range fallback?** Recommend exact-match + clear error now (schema-stability guarantee); treat any relaxation as a separate PR (§5.5, H1). Note the build changes every patch, so "build not yet in WoWDBDefs" is a routine, expected error, not an edge case. -9. **Slack tables.** Confirm none of the ~11 slack tables are read outside `tools/database` before treating their schema as non-critical (they may be staged for planned features). -10. **`SpellScaling.txt` double location.** Decide whether the committed `//go:embed` copy (`tools/database/dbc/GameTables/SpellScaling.txt`) should be auto-synced from the extracted `assets/db_inputs/basestats/SpellScaling.txt` or remain a manual, independently-committed file (status quo). -11. **Decrypt pre-release content? (optional, post-v1).** The tool has always run keyless (no `WoW.txt`), so encrypted sections are skipped and the sim omits unreleased items/spells. If that ever matters, decide whether to add the Salsa20 + `WoW.txt` decrypt path — noting it (a) changes output vs the golden, (b) needs a licensing read (TACTKeys has no license), and (c) needs a key-refresh story. Default: stay keyless. \ No newline at end of file diff --git a/makefile b/makefile index 828ac41020..c28d625d0f 100644 --- a/makefile +++ b/makefile @@ -248,14 +248,14 @@ CLIENTDATA_OUTPUT := $(shell realpath ./tools/database/wowsims.db) .PHONY: db db: - @echo "Extracting client data (pure Go)" + @echo "Extracting client data" go run ./tools/db2tool -s $(CLIENTDATA_SETTINGS) --output $(CLIENTDATA_OUTPUT) @echo "Running DBC generation tool" go run tools/database/gen_db/*.go -outDir=./assets -gen=db .PHONY: ptrdb ptrdb: - @echo "Extracting client data (pure Go)" + @echo "Extracting client data" go run ./tools/db2tool -s $(CLIENTDATAPTR_SETTINGS) --output $(CLIENTDATA_OUTPUT) @echo "Running DBC generation tool" go run tools/database/gen_db/*.go -outDir=./assets -gen=db diff --git a/tools/db2tool/NOTICES.md b/tools/db2tool/NOTICES.md index 989f39e9f4..4a570937f2 100644 --- a/tools/db2tool/NOTICES.md +++ b/tools/db2tool/NOTICES.md @@ -1,25 +1,24 @@ # Third-party notices for `tools/db2tool` -This tool is a pure-Go reimplementation of `tools/DB2ToSqlite` (.NET). Several -packages are Go translations (derivative works) of upstream C# libraries. Each -derived source file carries a short notice header pointing here; this file is -the authoritative list of upstreams, licenses, and pinned revisions. +Several packages of this tool are Go translations (derivative works) of +upstream C# libraries. Each derived source file carries a short notice header +pointing here; this file is the authoritative list of upstreams, licenses, +and pinned revisions. | Package dir | Upstream | License | Pinned revision | |---|---|---|---| -| `wdc/` | [wowdev/DBCD](https://github.com/wowdev/DBCD) (DBCD + DBCD.IO, v2.1.2 — the version vendored as DLLs in `tools/DB2ToSqlite/references/`) | MIT, Copyright (c) 2020 wowdev | `2180edb4d08b3822b3cfa964293ba8ccd4236ac0` | -| `dbd/` | [wowdev/WoWDBDefs](https://github.com/wowdev/WoWDBDefs) `code/C#/DBDefsLib` (**code** is BSD-3-Clause; the `.dbd` **data** files are CC BY-SA 4.0 and are fetched at build time, never vendored) | BSD-3-Clause, Copyright 2022 WoWDBDefs Contributors | `9002c532853a96d631c76dda50cb20189c27a173` (master at port time; the vendored DBDefsLib.dll is v1.0.0 with no embedded commit) | +| `wdc/` | [wowdev/DBCD](https://github.com/wowdev/DBCD) (DBCD + DBCD.IO, v2.1.2) | MIT, Copyright (c) 2020 wowdev | `2180edb4d08b3822b3cfa964293ba8ccd4236ac0` | +| `dbd/` | [wowdev/WoWDBDefs](https://github.com/wowdev/WoWDBDefs) `code/C#/DBDefsLib` (**code** is BSD-3-Clause; the `.dbd` **data** files are CC BY-SA 4.0 and are fetched at build time, never vendored) | BSD-3-Clause, Copyright 2022 WoWDBDefs Contributors | `9002c532853a96d631c76dda50cb20189c27a173` (master at port time) | | `tact/` | [wowdev/TACTSharp](https://github.com/wowdev/TACTSharp) v0.0.13-alpha | MIT, Copyright (c) 2024 Martin Benjamins | `d0ab516eb98b5db35682467b6e4977d88955046d` | -| `wdc/hotfix.go` (cache scanning + SStrHash; the XFTH reader itself derives from DBCD above) | [Marlamin/wow.tools.local](https://github.com/Marlamin/wow.tools.local) `Services/{HotfixManager,DBCacheParser}.cs`, ported via this repo's committed copies at `tools/DB2ToSqlite/Helpers/` | MIT, Copyright (c) 2022 Martin Benjamins | `0aefbece74ef4e19ce67ebe91b51a8ae424c5c11` (upstream main at port time; the in-repo copies are the direct source) | -| `sqlite/`, `config/`, `main.go` | original repo code (ports of this repo's own `tools/DB2ToSqlite/Helpers/*.cs` and `Program.cs`) | repo MIT | — | +| `wdc/hotfix.go` (cache scanning + SStrHash; the XFTH reader itself derives from DBCD above) | [Marlamin/wow.tools.local](https://github.com/Marlamin/wow.tools.local) `Services/{HotfixManager,DBCacheParser}.cs` | MIT, Copyright (c) 2022 Martin Benjamins | `0aefbece74ef4e19ce67ebe91b51a8ae424c5c11` (upstream main at port time) | +| `sqlite/`, `config/`, `main.go` | original repo code | repo MIT | — | -Runtime data dependencies (fetched, never vendored — see §4 of -`docs/db2tool-migration-plan.md`): +Runtime data dependencies (fetched, never vendored): - `.dbd` definitions from WoWDBDefs (`definitions/
.dbd`) — CC BY-SA 4.0, cached under a gitignored `DBDCache/`. - `listfile.csv` (community listfile) — cached, gitignored. -- No TACT keys are used; encrypted DB2 sections are skipped (plan §7 C1). +- No TACT keys are used; encrypted DB2 sections are skipped. ## MIT License (wowdev/DBCD, wowdev/TACTSharp, Marlamin/wow.tools.local) diff --git a/tools/db2tool/config/config.go b/tools/db2tool/config/config.go index 7a50ec9555..2694fe0fc2 100644 --- a/tools/db2tool/config/config.go +++ b/tools/db2tool/config/config.go @@ -1,6 +1,5 @@ -// Settings JSON binding for tools/db2tool — mirrors the configuration shape -// consumed by tools/DB2ToSqlite/Program.cs (generator-settings.json / -// ptr-generator-settings.json). Original repo code (MIT). +// Settings JSON binding for tools/db2tool (generator-settings.json / +// ptr-generator-settings.json). package config import ( @@ -9,9 +8,9 @@ import ( "os" ) -// Settings mirrors the TACTSharp-bindable "Settings" section. Only the fields -// the tool actually consumes are used today; the rest are bound for -// compatibility (CacheDir is bound-but-unused in v1, plan §3.1). +// Settings is the settings file's "Settings" section. Only the fields the +// tool actually consumes are used today; the rest are bound so existing +// settings files parse cleanly (CacheDir and Locale are bound-but-unused). type Settings struct { Region string `json:"Region"` Product string `json:"Product"` @@ -24,11 +23,11 @@ type Settings struct { type File struct { Settings Settings `json:"Settings"` - // TargetDirectory does double duty upstream (listfile-key prefix AND - // output dir — plan §7 M4); the FDID/listfile use must always see the raw - // value, never a filesystem-resolved path. + // TargetDirectory does double duty (listfile-key prefix AND output dir); + // the FDID/listfile use must always see the raw value, never a + // filesystem-resolved path. TargetDirectory string `json:"TargetDirectory"` - DatabaseFile string `json:"DatabaseFile"` // dead code upstream (plan §10 Q7); bound, never read + DatabaseFile string `json:"DatabaseFile"` // bound, never read — the --output flag decides the path GameTablesOutDirectory string `json:"GameTablesOutDirectory"` GameTables []string `json:"GameTables"` Tables []string `json:"Tables"` diff --git a/tools/db2tool/dbd/dbd.go b/tools/db2tool/dbd/dbd.go index 7363d8e7ac..6a83a5cbd1 100644 --- a/tools/db2tool/dbd/dbd.go +++ b/tools/db2tool/dbd/dbd.go @@ -126,8 +126,9 @@ func ReadFile(path string, validate bool) (DBDefinition, error) { return def, nil } -// Read parses a .dbd definition stream. It is a line-for-line transcription of -// DBDReader.Read (deliberately bug-for-bug where behavior is observable). +// Read parses a .dbd definition stream. It is a line-for-line transcription +// of the upstream reader (deliberately faithful even where behavior is +// quirky). func Read(r io.Reader, validate bool) (DBDefinition, error) { raw, err := io.ReadAll(r) if err != nil { @@ -303,7 +304,8 @@ func Read(r io.Reader, validate bool) (DBDefinition, error) { definition.IsRelation = true } } - // C#: line = line.Remove(annotationStart, annotationEnd + 1) + // Upstream removes annotationEnd+1 chars from annotationStart + // (not the annotation's span); replicated faithfully. line = line[:annotationStart] + line[annotationStart+annotationEnd+1:] } @@ -387,9 +389,9 @@ func Read(r io.Reader, validate bool) (DBDefinition, error) { }, nil } -// runValidation ports the optional validate block of DBDReader.Read. Console -// warnings become stderr prints; exceptions become errors. It also removes -// column definitions never used by any version block, as upstream does. +// runValidation is the optional validation pass: warnings go to stderr, hard +// violations become errors. It also removes column definitions never used by +// any version block, as upstream does. func runValidation(columnDefinitions map[string]ColumnDefinition, versionDefinitions []VersionDefinitions) error { for name := range columnDefinitions { found := false @@ -506,9 +508,9 @@ func stringSlicesEqual(a, b []string) bool { return true } -// readLines splits raw file bytes exactly like C# StreamReader.ReadLine: -// \r\n, \r, and \n all terminate a line, a terminator at EOF does not produce -// a trailing empty line, and a leading UTF-8 BOM is stripped. +// readLines splits raw file bytes into lines: \r\n, \r, and \n all terminate +// a line, a terminator at EOF does not produce a trailing empty line, and a +// leading UTF-8 BOM is stripped. func readLines(raw []byte) []string { s := string(raw) s = strings.TrimPrefix(s, "\ufeff") diff --git a/tools/db2tool/dbd/dbd_test.go b/tools/db2tool/dbd/dbd_test.go index d324584347..62c665e648 100644 --- a/tools/db2tool/dbd/dbd_test.go +++ b/tools/db2tool/dbd/dbd_test.go @@ -7,12 +7,11 @@ import ( "testing" ) -// Fixture values are frozen from the build-68571 snapshot in -// tools/DB2ToSqlite/DBDCache (plan §8 step 2). The tests skip when the -// gitignored snapshot is absent (e.g. CI). +// Fixture values are frozen from the gitignored build-68571 .dbd snapshot. +// The tests skip when the snapshot is absent (e.g. CI). const snapshotBuild = 68571 -const dbdCacheDir = "../../DB2ToSqlite/DBDCache" +const dbdCacheDir = "../refs/DBDCache" func snapshotFiles(t *testing.T) []string { t.Helper() @@ -58,7 +57,7 @@ func TestParseSnapshotAndSelect68571(t *testing.T) { } } - // Frozen per-build totals for the selected 68571 blocks (plan §8 step 2). + // Frozen per-build totals for the selected 68571 blocks. want := map[string]int{"int": 515, "float": 65, "locstring": 43, "string": 5} for typ, n := range want { if typeCounts[typ] != n { diff --git a/tools/db2tool/dbd/fetch.go b/tools/db2tool/dbd/fetch.go index e976d181d1..756aa0204a 100644 --- a/tools/db2tool/dbd/fetch.go +++ b/tools/db2tool/dbd/fetch.go @@ -1,8 +1,7 @@ -// Fetch-and-cache for .dbd definitions, mirroring DBCD's GithubDBDProvider -// (https://github.com/wowdev/DBCD, MIT): fetch from WoWDBDefs master into a +// Fetch-and-cache for .dbd definitions: fetch from WoWDBDefs master into a // gitignored cache directory with a 24h-mtime freshness rule. The .dbd files // themselves are CC BY-SA 4.0 DATA and are deliberately cached, never -// vendored (plan §4). +// vendored. package dbd import ( @@ -18,8 +17,8 @@ const dbdURLFormat = "https://raw.githubusercontent.com/wowdev/WoWDBDefs/master/ // FetchCached returns the path to a cached .dbd for tableName under cacheDir, // fetching from WoWDBDefs when the cached copy is absent or older than 24h. -// On a failed refresh of an existing copy, the stale copy is used (matching -// the provider's tolerance); a missing copy that cannot be fetched is fatal. +// On a failed refresh of an existing copy, the stale copy is used; a missing +// copy that cannot be fetched is fatal. func FetchCached(cacheDir, tableName string) (string, error) { if err := os.MkdirAll(cacheDir, 0o755); err != nil { return "", err diff --git a/tools/db2tool/dbd/select.go b/tools/db2tool/dbd/select.go index 03fad209bf..03bf1f8bfb 100644 --- a/tools/db2tool/dbd/select.go +++ b/tools/db2tool/dbd/select.go @@ -1,5 +1,4 @@ -// Go translation of the version-selection rule used by this repo's -// SQLiteDbCreator.cs / SqliteDataInserter.cs (see docs/db2tool-migration-plan.md §5.1). +// Version selection for .dbd definitions. // Derived from DBDefsLib types (https://github.com/wowdev/WoWDBDefs). // Copyright 2022 WoWDBDefs Contributors. BSD-3-Clause — see tools/db2tool/NOTICES.md. package dbd @@ -9,13 +8,10 @@ import "fmt" // SelectVersion returns the LAST versionDefinition (in file order) whose // Builds list contains an entry with trailing build number == buildNumber. // -// This is deliberately the SQLite helpers' rule replicated bug-for-bug: -// exact equality on the trailing build number only — buildRanges and -// layoutHashes are NOT consulted (plan §5.1). The C# row-decode half uses a -// different rule (first match on the full 4-part version, with range and -// layout-hash fallbacks); at the time of the port both rules resolve to the -// same block for every configured table, and this single selector fails loud -// exactly where the two C# halves would diverge. +// Exact equality on the trailing build number only — buildRanges and +// layoutHashes are deliberately NOT consulted: WoWDBDefs lists the live +// builds explicitly for every configured table, and failing loud here beats +// silently decoding with a near-miss layout. func SelectVersion(def DBDefinition, buildNumber uint32) (VersionDefinitions, error) { for i := len(def.VersionDefinitions) - 1; i >= 0; i-- { for _, b := range def.VersionDefinitions[i].Builds { diff --git a/tools/db2tool/golden_test.go b/tools/db2tool/golden_test.go index 7909dc656a..75cb8a8daa 100644 --- a/tools/db2tool/golden_test.go +++ b/tools/db2tool/golden_test.go @@ -14,22 +14,21 @@ import ( _ "modernc.org/sqlite" ) -// Golden gate (plan §8): builds a wowsims.db from the pre-extracted snapshot -// and diffs it against a reference produced by the .NET tool. +// Golden gate: builds a wowsims.db from the pre-extracted build-68571 +// snapshot and diffs it against a reference database capture. // // - Schema parity is ALWAYS strict — hotfixes never change schema. // - Row parity is strict when DB2TOOL_REF_DB points at a without-hotfix -// reference capture (plan §8 step 1). Against the default repo reference +// reference capture. Against the default repo reference // (tools/database/wowsims.db, built WITH hotfixes), small per-table diffs -// are tolerated and logged: they are the hotfix overlay Phase D will -// apply. A systematic decoder bug produces thousands of diff lines and -// still fails. +// are tolerated and logged: they are the hotfix overlay. A systematic +// decoder bug produces thousands of diff lines and still fails. // // Skips when the gitignored snapshot/reference are absent (e.g. CI). func TestGoldenParity(t *testing.T) { const snapshotBuild = 68571 - db2Dir := "../DB2ToSqlite/dbfilesclient" - dbdDir := "../DB2ToSqlite/DBDCache" + db2Dir := "refs/dbfilesclient" + dbdDir := "refs/DBDCache" settingsPath := "../database/generator-settings.json" refPath := os.Getenv("DB2TOOL_REF_DB") @@ -118,8 +117,9 @@ func TestGoldenParity(t *testing.T) { } } - // 2. §5.5 float-notation risk: no critical-table float ARRAY element may - // fall in the C#-vs-Go divergent text ranges. Scalars are exempt: they + // 2. Float-notation risk: no critical-table float ARRAY element may fall + // in the divergent text-rendering ranges (golden.FloatDiverges). Scalars + // are exempt: they // bind numerically as REAL and never go through text formatting (e.g. // SpellEffect has ±1e17 scalar coefficients that are byte-identical in // the reference). @@ -137,7 +137,7 @@ func TestGoldenParity(t *testing.T) { case []float32: for _, f := range v { if golden.FloatDiverges(f) { - t.Errorf("%s row %d: float %v in divergent notation range (implement C#-compatible formatter, §5.5)", tableName, decodedRow.ID, f) + t.Errorf("%s row %d: float %v in divergent notation range (needs a reference-compatible formatter)", tableName, decodedRow.ID, f) } } } @@ -182,7 +182,7 @@ func TestGoldenParity(t *testing.T) { } t.Errorf("%s: %d row diff lines", td.Name, n) } else { - t.Logf("%s: %d diff lines (within with-hotfix tolerance — expected Phase D deltas)", td.Name, n) + t.Logf("%s: %d diff lines (within with-hotfix tolerance — expected hotfix-overlay deltas)", td.Name, n) } } t.Logf("total row diff lines across all tables: %d", totalDiff) diff --git a/tools/db2tool/hotfix_golden_test.go b/tools/db2tool/hotfix_golden_test.go index a3f7a6bf88..8ac415e036 100644 --- a/tools/db2tool/hotfix_golden_test.go +++ b/tools/db2tool/hotfix_golden_test.go @@ -15,19 +15,18 @@ import ( _ "modernc.org/sqlite" ) -// Phase D golden gate (plan §6/§8): builds a wowsims.db from the -// pre-extracted snapshot WITH the refs/DBCache.68571.bin hotfix overlay -// applied, and diffs it against refs/wowsims.hotfix.db — a .NET-tool capture -// produced with that same cache. Row parity is strict for every table; the -// only tolerated divergence is the documented CurvePoint Id=236585 float -// notation (plan §5.5: .NET "[1,-6E-05]" vs Go "[1,-0.00006]") — exactly one -// line per side. +// Hotfix golden gate: builds a wowsims.db from the pre-extracted build-68571 +// snapshot WITH the refs/DBCache.68571.bin hotfix overlay applied, and diffs +// it against refs/wowsims.hotfix.db — a reference capture produced with that +// same cache. Row parity is strict for every table; the only tolerated +// divergence is the documented CurvePoint Id=236585 float notation +// (reference "[1,-6E-05]" vs Go "[1,-0.00006]") — exactly one line per side. // // Skips when the gitignored snapshot/refs assets are absent (e.g. CI). func TestHotfixGoldenParity(t *testing.T) { const snapshotBuild = 68571 - db2Dir := "../DB2ToSqlite/dbfilesclient" - dbdDir := "../DB2ToSqlite/DBDCache" + db2Dir := "refs/dbfilesclient" + dbdDir := "refs/DBDCache" settingsPath := "../database/generator-settings.json" cachePath := "refs/DBCache.68571.bin" refPath := "refs/wowsims.hotfix.db" @@ -144,7 +143,7 @@ func TestHotfixGoldenParity(t *testing.T) { if td.Name == "CurvePoint" && len(refOnly) == 1 && len(goOnly) == 1 && strings.Contains(refOnly[0], "|236585|") && strings.Contains(refOnly[0], "-6E-05") && strings.Contains(goOnly[0], "|236585|") && strings.Contains(goOnly[0], "-0.00006") { - t.Logf("CurvePoint: known Id=236585 float-notation divergence (2 lines, plan §5.5)") + t.Logf("CurvePoint: known Id=236585 float-notation divergence (2 lines)") continue } for i, l := range refOnly { diff --git a/tools/db2tool/internal/golden/golden.go b/tools/db2tool/internal/golden/golden.go index 0b4bb7aadb..99f855a648 100644 --- a/tools/db2tool/internal/golden/golden.go +++ b/tools/db2tool/internal/golden/golden.go @@ -1,6 +1,6 @@ -// Package golden is the validation harness for tools/db2tool (plan §8): it -// compares a Go-built wowsims.db against a reference produced by the .NET -// tool — schema DDL, per-table row counts, and canonical row dumps. +// Package golden is the validation harness for tools/db2tool: it compares a +// freshly built wowsims.db against a captured reference database — schema +// DDL, per-table row counts, and canonical row dumps. package golden import ( @@ -10,7 +10,7 @@ import ( "strings" ) -// CriticalTables is the §5.6 byte-exact-critical set: row + value parity +// CriticalTables is the byte-exact-critical set: row + value parity // required. Slack tables must merely extract without error. var CriticalTables = []string{ "Item", "ItemSparse", "SpellEffect", "SpellItemEnchantment", "ItemRandomSuffix", @@ -153,10 +153,10 @@ func DiffLines(ref, got []string) (refOnly, gotOnly []string) { return refOnly, gotOnly } -// FloatDivergenceRanges reports whether a float32 value falls where C#'s and -// Go's shortest-round-trip text renderings diverge (plan §5.5): C# switches -// to scientific notation for |v| < 1e-4 or >= 1e15, Go only below 1e-6 or at -// >= 1e21. Zero is fine. +// FloatDiverges reports whether a float32 value falls where the reference +// database's shortest-round-trip text rendering and Go's diverge: the +// reference switches to scientific notation for |v| < 1e-4 or >= 1e15, Go +// only below 1e-6 or at >= 1e21. Zero is fine. func FloatDiverges(v float32) bool { a := math.Abs(float64(v)) if a == 0 { diff --git a/tools/db2tool/main.go b/tools/db2tool/main.go index a42221e4bf..19946bd975 100644 --- a/tools/db2tool/main.go +++ b/tools/db2tool/main.go @@ -1,17 +1,16 @@ -// db2tool extracts World of Warcraft client data into tools/database/wowsims.db, -// replacing the .NET tools/DB2ToSqlite tool (see docs/db2tool-migration-plan.md). +// db2tool extracts World of Warcraft client data into tools/database/wowsims.db. // -// Default (Phase B) mode reads the local install named by the settings' -// BaseDir: .build.info picks the build, files come from local CASC +// The default mode reads the local install named by the settings' BaseDir: +// .build.info picks the build, files come from local CASC // (root → encoding → .idx → data.NNN → BLTE), .dbd definitions and the // community listfile are fetched/cached over plain HTTPS. The client's // DBCache.bin hotfixes for the extracted build are applied to the decoded -// rows (Phase D); --dbcache pins specific cache files instead of the -// default scan and --no-hotfixes disables the overlay. +// rows; --dbcache pins specific cache files instead of the default +// scan and --no-hotfixes disables the overlay. // -// With --build (and optionally --db2dir/--dbddir), the offline Phase A mode -// decodes pre-extracted .db2 files instead — no install required and no -// hotfixes unless --dbcache is given. +// With --build (and optionally --db2dir/--dbddir), the offline mode decodes +// pre-extracted .db2 files instead — no install required and no hotfixes +// unless --dbcache is given. package main import ( @@ -44,9 +43,8 @@ type options struct { noHotfixes bool // skip hotfix application entirely } -// parseArgs mirrors Program.cs's pairwise scan, including the flag aliases -// (--settings/-s, --output/-output/-o), plus the offline-mode and hotfix -// flags. +// parseArgs scans the args pairwise. --settings/-s and --output/-output/-o +// are aliases, plus the offline-mode and hotfix flags. func parseArgs(args []string) (options, error) { opts := options{ settingsFile: "appsettings.json", @@ -97,10 +95,8 @@ func parseArgs(args []string) (options, error) { } // resolvePath resolves a possibly-relative settings path against the tool -// home directory tools/db2tool (plan §7 M4). Relative settings values like -// "../../assets/db_inputs/basestats" were written for a CWD of -// tools/DB2ToSqlite; tools/db2tool sits at the same depth, so they keep -// meaning what they always meant. +// home directory tools/db2tool — relative settings values like +// "../../assets/db_inputs/basestats" are anchored there. func resolvePath(toolHome, value string) string { if filepath.IsAbs(value) { return value @@ -145,7 +141,7 @@ func run(args []string) error { var openTable func(tableName string) (*wdc.Table, error) if opts.buildNumber != 0 { - // Offline (Phase A) mode: pre-extracted .db2 files. + // Offline mode: pre-extracted .db2 files. buildNumber = opts.buildNumber db2Dir := opts.db2Dir if db2Dir == "" { @@ -192,7 +188,7 @@ func run(args []string) error { // Tables: extract each .db2 to the target directory, then parse it. // The FDID key uses the RAW settings TargetDirectory value; only the - // on-disk output use is resolved (plan §7 M4 carve-out). + // on-disk output use is resolved. targetDirOnDisk := resolvePath(toolHome, settings.TargetDirectory) if err := os.MkdirAll(targetDirOnDisk, 0o755); err != nil { return err @@ -258,13 +254,12 @@ func run(args []string) error { return err } - // Hotfixes (Phase D): overlay the client's DBCache.bin records before the - // inserts, mirroring Program.cs steps 10–11. Only a cache for this exact - // build applies; having none is not an error (Program.cs:102's throw is - // commented out). --dbcache pins specific cache files (deterministic - // runs); with no override, local-CASC mode scans tools/db2tool/caches - // plus /**/DBCache.bin like HotfixManager.LoadCaches, while the - // offline --build mode stays hotfix-free. + // Hotfixes: overlay the client's DBCache.bin records before the inserts. + // Only a cache for this exact build applies; having none is not an error. + // --dbcache pins specific cache files (deterministic runs); with no + // override, local-CASC mode scans tools/db2tool/caches plus + // /**/DBCache.bin, while the offline --build mode stays + // hotfix-free. var hotfixReader *wdc.HotfixReader if !opts.noHotfixes { var readers map[uint32]*wdc.HotfixReader diff --git a/tools/db2tool/sqlite/insert.go b/tools/db2tool/sqlite/insert.go index ff662332ef..e8e900e31e 100644 --- a/tools/db2tool/sqlite/insert.go +++ b/tools/db2tool/sqlite/insert.go @@ -1,5 +1,4 @@ -// Port of this repo's tools/DB2ToSqlite/Helpers/SqliteDataInserter.cs. -// Original repo code (MIT), no external attribution owed. +// Row insertion for the extracted tables. package sqlite import ( @@ -14,17 +13,15 @@ import ( // InsertRows upserts every decoded row of one table inside one transaction. // -// Contract notes (plan §5.4/§5.5, all verified against the reference DB): +// Contract notes (the wowsims.db output format): // - definition order = column order = bind order; // - relation-column idx_ indexes use table-less names (idx_) // with IF NOT EXISTS, so only the first table processed with a given // relation-column name gets the index — tables MUST be processed in // settings order; -// - relation values of 0 stay 0 (the C# 0→NULL branch is a boxed reference -// comparison that is always false — dead code); +// - relation values of 0 stay 0, never NULLed; // - arrays serialize as JSON text via encoding/json over plain numeric -// slices, matching C# System.Text.Json's Array-declared serialization -// (boxed elements — u8 arrays emit [0,0,0], never base64); +// slices (u8 arrays emit [0,0,0], never base64); // - float scalars bind as the double-widened float32. func InsertRows(db *sql.DB, t TableDef, decoded *wdc.Decoded) error { defs := t.Version.Definitions @@ -71,7 +68,7 @@ func InsertRows(db *sql.DB, t TableDef, decoded *wdc.Decoded) error { } defer tx.Rollback() - // Relation-column indexes, created before the inserts like the C# tool. + // Relation-column indexes, created before the inserts. for _, d := range defs { if d.IsRelation { stmt := fmt.Sprintf("CREATE INDEX IF NOT EXISTS idx_%s ON %s (%s);", strings.ToLower(d.Name), t.Name, d.Name) @@ -120,7 +117,7 @@ func bindValue(value any) (any, error) { } return int64(v), nil case float32: - return float64(v), nil // double-widened, same as Microsoft.Data.Sqlite + return float64(v), nil // REAL binds as the double-widened float32 case string: return v, nil case []int64, []uint64, []float32, []string: diff --git a/tools/db2tool/sqlite/schema.go b/tools/db2tool/sqlite/schema.go index e3d1c70ef7..e03af794d7 100644 --- a/tools/db2tool/sqlite/schema.go +++ b/tools/db2tool/sqlite/schema.go @@ -1,5 +1,4 @@ -// Port of this repo's tools/DB2ToSqlite/Helpers/SQLiteDbCreator.cs. -// Original repo code (MIT), no external attribution owed. +// SQLite schema creation for the extracted tables. package sqlite import ( @@ -12,17 +11,17 @@ import ( ) // TableDef pairs a table name with its parsed definition and the version -// block selected for the current build (plan §5.1 selection rule; the caller -// selects once and both schema and inserts use the same block). +// block selected for the current build (the caller selects once and both +// schema and inserts use the same block). type TableDef struct { Name string Def dbd.DBDefinition Version dbd.VersionDefinitions } -// Open deletes any pre-existing database file (SQLiteDbCreator.cs:11 — every -// run starts from an empty file; this is what makes post-patch re-runs and -// db/ptrdb alternation correct, plan §5) and opens a fresh connection with +// Open deletes any pre-existing database file — every run starts from an +// empty file, which is what makes post-patch re-runs and db/ptrdb +// alternation correct — and opens a fresh connection with // PRAGMA foreign_keys = ON. func Open(path string) (*sql.DB, error) { if _, err := os.Stat(path); err == nil { @@ -34,8 +33,8 @@ func Open(path string) (*sql.DB, error) { if err != nil { return nil, err } - // The writer is single-threaded; a single connection keeps transaction - // semantics identical to the C# tool's one SqliteConnection. + // The writer is single-threaded; a single connection keeps every + // statement on one session. db.SetMaxOpenConns(1) if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil { db.Close() @@ -45,7 +44,7 @@ func Open(path string) (*sql.DB, error) { } // CreateTables emits the schema for every table, in order, inside one -// transaction — a transcription of SQLiteDbCreator.CreateDatabaseWithDefinitions. +// transaction. func CreateTables(db *sql.DB, tables []TableDef) error { tx, err := db.Begin() if err != nil { diff --git a/tools/db2tool/sqlite/sqlite_test.go b/tools/db2tool/sqlite/sqlite_test.go index b293ea95df..09cecd90b4 100644 --- a/tools/db2tool/sqlite/sqlite_test.go +++ b/tools/db2tool/sqlite/sqlite_test.go @@ -10,10 +10,11 @@ import ( _ "modernc.org/sqlite" ) -// modernc driver-marshaling smoke test (plan §8 step 7): creates the §5.2/§5.3 -// schema shapes, upserts via named params, and reads back json_extract virtual -// columns, NULL scans, and REAL vs INTEGER marshaling — a permanent regression -// gate for driver parity, independent of any game-data snapshot. +// modernc driver-marshaling smoke test: creates the extractor's schema +// shapes, upserts via named params, and reads back json_extract virtual +// columns, NULL scans, and REAL vs INTEGER marshaling — a permanent +// regression gate for driver behavior, independent of any game-data +// snapshot. func TestModerncMarshalingContract(t *testing.T) { path := filepath.Join(t.TempDir(), "smoke.db") @@ -98,12 +99,12 @@ func TestModerncMarshalingContract(t *testing.T) { } // json_extract parses the stored float32 shortest-round-trip TEXT ("0.1") // as a double — so virtual float columns yield 0.1, NOT the widened - // float32 0.10000000149011612. The C# reference behaves identically. + // float32 0.10000000149011612. if scales0 != 0.1 { t.Errorf("Scales_0 = %v, want 0.1", scales0) } - // All-zero arrays serialize as [0,...], never NULL/[]/"" (§5.5). + // All-zero arrays serialize as [0,...], never NULL/[]/"". var zeroStats, zeroScales string if err := db.QueryRow("SELECT Stats, Scales FROM Smoke WHERE ID=2").Scan(&zeroStats, &zeroScales); err != nil { t.Fatal(err) diff --git a/tools/db2tool/tact/blte.go b/tools/db2tool/tact/blte.go index d81e0c5bac..d54c14e76c 100644 --- a/tools/db2tool/tact/blte.go +++ b/tools/db2tool/tact/blte.go @@ -2,9 +2,9 @@ // v0.0.13-alpha, commit d0ab516eb98b5db35682467b6e4977d88955046d). // Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. // -// Keyless port: 'E' (encrypted) chunks are left zero-filled in the output — -// exactly what the .NET tool produces without TACT keys, and what the WDC -// layer's encrypted-section skip expects (plan §7 C1/M5). 'F' never occurs. +// Keyless: no TACT keys are loaded, so 'E' (encrypted) chunks are left +// zero-filled in the output — exactly what the WDC layer's encrypted-section +// skip expects. 'F' never occurs. package tact import ( @@ -88,8 +88,7 @@ func handleDataBlock(mode byte, compData, out []byte) error { _, err = io.ReadFull(zr, out) return err case 'E': - // Keyless: leave the output range zero-filled (upstream's TryDecrypt - // finds no key and writes nothing). + // Keyless: leave the output range zero-filled. return nil case 'F': return fmt.Errorf("BLTE frame ('F') decompression not implemented (never occurs in this data)") diff --git a/tools/db2tool/tact/buildinfo.go b/tools/db2tool/tact/buildinfo.go index b39874c82c..100f6579a3 100644 --- a/tools/db2tool/tact/buildinfo.go +++ b/tools/db2tool/tact/buildinfo.go @@ -59,8 +59,7 @@ func ParseBuildInfo(path string) ([]AvailableBuild, error) { return entries, nil } -// SelectBuild returns the first entry for the given product (Program.cs uses -// Entries.First(x => x.Product == settings.Product)). +// SelectBuild returns the first entry for the given product. func SelectBuild(entries []AvailableBuild, product string) (AvailableBuild, error) { for _, e := range entries { if e.Product == product { @@ -70,8 +69,8 @@ func SelectBuild(entries []AvailableBuild, product string) (AvailableBuild, erro return AvailableBuild{}, fmt.Errorf("product %q not found in .build.info", product) } -// BuildNumber extracts the trailing build number from a 4-part version string -// (Program.cs: uint.Parse(Version.Split('.')[3])). +// BuildNumber extracts the trailing build number from a 4-part version +// string. func BuildNumber(version string) (uint32, error) { split := strings.Split(version, ".") if len(split) != 4 { diff --git a/tools/db2tool/tact/cascidx.go b/tools/db2tool/tact/cascidx.go index 09fc1d7001..2bccd99860 100644 --- a/tools/db2tool/tact/cascidx.go +++ b/tools/db2tool/tact/cascidx.go @@ -33,7 +33,7 @@ func loadCascIndex(path string) (*cascIndex, error) { if len(raw) < cascIdxHeaderSize { return nil, fmt.Errorf("%s: too small for .idx header", path) } - // IndexHeader layout (C# sequential struct with natural alignment): + // IndexHeader layout: // u32 headerHashSize, u32 headerHash, u16 version, u8 bucketIndex, // u8 extraBytes, u8 entrySizeBytes, u8 entryOffsetBytes, u8 entryKeyBytes, // u8 entryOffsetBits, u64 maxArchiveSize @16, 8 pad, u32 entriesSize @32. diff --git a/tools/db2tool/tact/config.go b/tools/db2tool/tact/config.go index 25f62f37d2..f829b6957d 100644 --- a/tools/db2tool/tact/config.go +++ b/tools/db2tool/tact/config.go @@ -13,7 +13,7 @@ import ( // LoadConfig reads a build/CDN config from the local install's // Data/config/// layout. Values are space-separated (typically // `ckey [ekey]`). All keys are kept, including the ~318 unused `vfs-*` TVFS -// lines (plan §10 Q4) — they parse fine and are simply never consulted. +// lines — they parse fine and are simply never consulted. func LoadConfig(baseDir, hash string) (map[string][]string, error) { if len(hash) != 32 { return nil, fmt.Errorf("invalid config hash %q", hash) diff --git a/tools/db2tool/tact/fdid.go b/tools/db2tool/tact/fdid.go index 12bc69a3a3..3067e79343 100644 --- a/tools/db2tool/tact/fdid.go +++ b/tools/db2tool/tact/fdid.go @@ -1,8 +1,6 @@ // FDID resolution: a static path→FDID map for the configured tables/gametables // (primary; FDIDs are stable per path), with the community listfile.csv as the -// fallback for paths not in the map. Replaces TACTSharp's Jenkins96-hashed -// listfile lookup (https://github.com/wowdev/TACTSharp) with plain -// case-normalized paths. Original repo code (MIT). +// fallback for paths not in the map. Lookups use plain case-normalized paths. package tact import ( @@ -101,8 +99,7 @@ var staticFDIDs = map[string]uint32{ // GetFDID resolves a game path (e.g. "dbfilesclient/Spell.db2") to its file // data id: static map first, then the listfile (loaded lazily). The lookup -// key is the raw game path, lowercased — never a filesystem-resolved path -// (plan §7 M4 carve-out). +// key is the raw game path, lowercased — never a filesystem-resolved path. func (l *Listfile) GetFDID(path string) (uint32, error) { key := strings.ToLower(path) if fdid, ok := staticFDIDs[key]; ok { diff --git a/tools/db2tool/tact/listfile.go b/tools/db2tool/tact/listfile.go index f3c15c510a..f49a83994d 100644 --- a/tools/db2tool/tact/listfile.go +++ b/tools/db2tool/tact/listfile.go @@ -14,10 +14,10 @@ import ( const DefaultListfileURL = "https://github.com/wowdev/wow-listfile/releases/latest/download/community-listfile.csv" -// Listfile manages the community listfile.csv: download-if-stale semantics -// matching upstream (HEAD + Last-Modified vs local mtime; on a failed -// freshness check upstream re-downloads, and on a failed download it falls -// back to the existing file when one exists). +// Listfile manages the community listfile.csv with download-if-stale +// semantics: HEAD + Last-Modified vs local mtime; a failed freshness check +// triggers a re-download, and a failed download falls back to the existing +// file when one exists. type Listfile struct { Path string URL string diff --git a/tools/db2tool/wdc/bitreader.go b/tools/db2tool/wdc/bitreader.go index d2c75ba850..bf9df831a6 100644 --- a/tools/db2tool/wdc/bitreader.go +++ b/tools/db2tool/wdc/bitreader.go @@ -8,13 +8,12 @@ import ( "math" ) -// bitReader reads unaligned little-endian bit windows exactly like the C# -// BitReader: a raw 4/8-byte load at the current byte, shifted left then right -// to isolate numBits. The C# code performs unchecked past-the-end loads (the -// reader pads record buffers with 8 zero bytes); newBitReader enforces the -// same padding so Go slice bounds are never exceeded. C# shift counts are -// masked (&31 / &63) by the CLR; the same masking is applied here so behavior -// is bug-for-bug identical even for degenerate widths. +// bitReader reads unaligned little-endian bit windows: a raw 4/8-byte load +// at the current byte, shifted left then right to isolate numBits. Loads can +// extend past the last meaningful byte, so record buffers must carry 8 zero +// bytes of padding (see padRecordData) to keep slice bounds safe. Shift +// counts are masked (&31 / &63) so degenerate widths (0 or full-width) +// behave consistently rather than panicking. type bitReader struct { data []byte Position int // in bits, relative to Offset @@ -27,9 +26,8 @@ func newBitReader(data []byte) *bitReader { return &bitReader{data: data} } -// padRecordData appends 8 zero bytes, mirroring WDC5Reader's -// Array.Resize(ref data, data.Length + 8) and making unaligned loads at the -// tail safe. The extra bytes are always masked out of results. +// padRecordData appends 8 zero bytes, making unaligned loads at the tail +// safe. The extra bytes are always masked out of results. func padRecordData(data []byte) []byte { // Must copy: data may alias the file buffer, and appending in place would // overwrite the bytes that follow the record block. @@ -53,7 +51,7 @@ func (r *bitReader) ReadUInt64(numBits int) uint64 { } // ReadValue64 returns the raw (zero-extended) bits; the caller reinterprets -// them per the DBD-declared field type (value64 semantics). +// them per the DBD-declared field type. func (r *bitReader) ReadValue64(numBits int) uint64 { return r.ReadUInt64(numBits) } @@ -81,7 +79,7 @@ func (r *bitReader) clone() *bitReader { return &bitReader{data: r.data} } -// value32 mirrors C# Value32: 4 raw bytes reinterpreted on demand. +// value32 is 4 raw bytes reinterpreted on demand. type value32 uint32 func (v value32) Float32() float32 { return math.Float32frombits(uint32(v)) } diff --git a/tools/db2tool/wdc/hotfix.go b/tools/db2tool/wdc/hotfix.go index 27a28391aa..cc7e88bba0 100644 --- a/tools/db2tool/wdc/hotfix.go +++ b/tools/db2tool/wdc/hotfix.go @@ -1,9 +1,7 @@ -// Go translation of DBCD.IO's hotfix support — HotfixReader, HTFXReader and -// HotfixEntryV9 (https://github.com/wowdev/DBCD, v2.1.2, commit -// 2180edb4d08b3822b3cfa964293ba8ccd4236ac0) — plus the DBCache scanning and -// SStrHash table-name hash ported from wow.tools.local's HotfixManager / -// DBCacheParser (https://github.com/Marlamin/wow.tools.local, via this repo's -// tools/DB2ToSqlite/Helpers copies). +// Go translation of DBCD.IO's hotfix support (https://github.com/wowdev/DBCD, +// v2.1.2, commit 2180edb4d08b3822b3cfa964293ba8ccd4236ac0), plus the DBCache +// scanning and SStrHash table-name hash from wow.tools.local +// (https://github.com/Marlamin/wow.tools.local). // Copyright (c) 2020 wowdev; Copyright (c) 2022 Martin Benjamins. // MIT License — see tools/db2tool/NOTICES.md. package wdc @@ -37,11 +35,9 @@ type HotfixEntry struct { Data []byte } -// hotfixIdentity is the effective HashSet identity Combine dedups -// on. HTFXRow.Equals compares the 5-tuple, but GetHashCode also hashes the -// record's data bytes (BitReader.GetHashCode), so records that differ only -// in data land in different buckets and are both kept — full-record identity, -// not the 5-tuple alone (plan §6 Phase D). RegionID/UniqueID never +// hotfixIdentity is the identity Combine dedups on: the 5-tuple plus the +// record's data bytes, so records that differ only in data are both kept — +// full-record identity, not the 5-tuple alone. RegionID/UniqueID never // participate. type hotfixIdentity struct { pushID int32 @@ -72,9 +68,8 @@ type HotfixReader struct { } // ReadHotfixFile parses one DBCache-format file. Only XFTH version 9 (the -// current live-client format) is supported; older versions fail loud (the C# -// HTFXReader also handles v1–v8 for long-obsolete clients — deliberately not -// ported, plan's minimal-surface rule). +// current live-client format) is supported; older versions (v1–v8, written +// only by long-obsolete clients) fail loud. func ReadHotfixFile(path string) (*HotfixReader, error) { buf, err := os.ReadFile(path) if err != nil { @@ -136,9 +131,9 @@ func parseHotfix(buf []byte) (*HotfixReader, error) { return h, nil } -// Combine ports HTFXReader.Combine (+ HotfixReader.CombineCache's build -// check): other's records are appended in order unless an identical record -// is already present. Readers for a different build are ignored. +// Combine merges another reader into this one: other's records are appended +// in order unless an identical record is already present. Readers for a +// different build are ignored. func (h *HotfixReader) Combine(other *HotfixReader) { if other.BuildID != h.BuildID { return @@ -158,8 +153,7 @@ func (h *HotfixReader) Combine(other *HotfixReader) { // CombineHotfixFiles parses each file in order into readers keyed by BuildId: // the first file seen for a build becomes its base reader and later files -// Combine into it — HotfixManager.LoadCaches's per-file loop. (C# also -// re-Combines the base file into itself, a no-op under the dedup.) +// Combine into it. func CombineHotfixFiles(files []string) (map[uint32]*HotfixReader, error) { readers := make(map[uint32]*HotfixReader) for _, f := range files { @@ -177,12 +171,11 @@ func CombineHotfixFiles(files []string) (map[uint32]*HotfixReader, error) { return readers, nil } -// LoadHotfixCaches ports HotfixManager.LoadCaches's scan: if cachesDir -// exists, every *.bin under it (recursively) loads first, then every file -// named DBCache.bin anywhere under baseDir. Finding no cache file is not an -// error (the no-hotfix throw in Program.cs:102 is commented out); a malformed +// LoadHotfixCaches scans for cache files: if cachesDir exists, every *.bin +// under it (recursively) loads first, then every file named DBCache.bin +// anywhere under baseDir. Finding no cache file is not an error; a malformed // or unsupported-version file fails loud. Files are visited in WalkDir's -// deterministic lexical order (.NET's enumeration order is unspecified). +// deterministic lexical order. func LoadHotfixCaches(cachesDir, baseDir string) (map[uint32]*HotfixReader, error) { var files []string if st, err := os.Stat(cachesDir); err == nil && st.IsDir() { @@ -214,10 +207,9 @@ func LoadHotfixCaches(cachesDir, baseDir string) (map[uint32]*HotfixReader, erro return CombineHotfixFiles(files) } -// SStrHash ports HotfixManager.Hash — the Blizzard SStrHash variant DBCache -// table hashes use. Callers hash the UPPERCASED table name; the result equals -// the table's WDC5 header TableHash (which ApplyHotfixes actually keys on, -// like C# parser.TableHash). +// SStrHash is the Blizzard SStrHash variant DBCache table hashes use. +// Callers hash the UPPERCASED table name; the result equals the table's WDC5 +// header TableHash (which ApplyHotfixes actually keys on). func SStrHash(s string) uint32 { sHashtable := [16]uint32{ 0x486E26EE, 0xDCAA16B3, 0xE1918EEF, 0x202DAFDB, @@ -236,13 +228,12 @@ func SStrHash(s string) uint32 { return v } -// ApplyHotfixes ports HotfixReader.ReadHotfixes (with DefaultProcessor) + -// DBCDStorage.ApplyingHotfixes: this reader's records for table t overlay -// decoded in place. Records apply in a stable ascending-PushId sort -// (file/combine insertion order preserved within a PushId). An Add +// ApplyHotfixes overlays this reader's records for table t onto decoded in +// place. Records apply in a stable ascending-PushId sort (file/combine +// insertion order preserved within a PushId). An Add // (IsValid && DataSize > 0) replaces or inserts the whole row decoded from // the blob; otherwise the row is deleted when shouldDelete. Rows come back -// out in ascending-ID order, keeping the Phase A/B insertion contract. +// out in ascending-ID order, the order the sqlite inserts expect. func (h *HotfixReader) ApplyHotfixes(t *Table, def dbd.DBDefinition, version dbd.VersionDefinitions, buildNumber uint32, decoded *Decoded) error { var recs []*HotfixEntry for i := range h.records { @@ -262,8 +253,7 @@ func (h *HotfixReader) ApplyHotfixes(t *Table, def dbd.DBDefinition, version dbd sort.SliceStable(recs, func(i, j int) bool { return recs[i].PushID < recs[j].PushID }) // The shouldDelete carve-out only affects TactKey (0xDF2F53CF) and - // BroadcastText (0x021826BB), neither of which is in Tables[] — ported - // faithfully anyway (plan §6 Phase D). + // BroadcastText (0x021826BB), neither of which is in Tables[]. anyValidCached := false for _, r := range recs { if r.IsValid && r.PushID == -1 && r.DataSize > 0 { @@ -306,12 +296,12 @@ func (h *HotfixReader) ApplyHotfixes(t *Table, def dbd.DBDefinition, version dbd return nil } -// decodeHotfixRow ports HTFXRow.GetFields. Hotfix blobs are NOT bitpacked: -// fields are byte-aligned little-endian values in definition order, at their -// DBD-declared widths, with strings inline null-terminated. The non-inline ID -// is absent from the blob (IndexMapField) and comes from RecordId; a -// non-inline relation IS in the blob at its DBD-declared type -// (MetaDataFieldType), then Convert.ChangeType'd to int. +// decodeHotfixRow decodes one hotfix data blob. Hotfix blobs are NOT +// bitpacked: fields are byte-aligned little-endian values in definition +// order, at their DBD-declared widths, with strings inline null-terminated. +// The non-inline ID is absent from the blob and comes from RecordId; a +// non-inline relation IS in the blob at its DBD-declared type, then +// converted to int. func decodeHotfixRow(t *Table, plans []fieldPlan, rec *HotfixEntry) ([]any, error) { r := newBitReader(padRecordData(rec.Data)) values := make([]any, len(plans)) @@ -319,9 +309,8 @@ func decodeHotfixRow(t *Table, plans []fieldPlan, rec *HotfixEntry) ([]any, erro for i := range plans { p := &plans[i] - // FieldCache.IndexMapField: set at construction for a non-inline DBD - // id, and forced by ReadHotfixes on parser.IdFieldIndex when the - // Index flag is set. + // The record ID replaces the field for a non-inline DBD id, and for + // the id field when the table's Index flag is set. if p.isNonInlineID || (t.Flags&flagIndex != 0 && i == int(t.IdFieldIndex)) { values[i] = int64(rec.RecordID) continue @@ -329,7 +318,6 @@ func decodeHotfixRow(t *Table, plans []fieldPlan, rec *HotfixEntry) ([]any, erro if p.isNonInlineRel { if p.arrLength != 0 { - // C# Convert.ChangeType to int[] would throw InvalidCastException. return nil, fmt.Errorf("field %s: non-inline relation arrays are not supported", p.name) } if p.hfKind != kindInt { @@ -344,8 +332,6 @@ func decodeHotfixRow(t *Table, plans []fieldPlan, rec *HotfixEntry) ([]any, erro } if p.arrLength != 0 { - // FieldCache.Cardinality: the CardinalityAttribute arrLength when - // > 1, else the default 1 — arrLength elements either way. switch p.kind { case kindString: out := make([]string, p.arrLength) @@ -386,12 +372,12 @@ func decodeHotfixRow(t *Table, plans []fieldPlan, rec *HotfixEntry) ([]any, erro values[i] = rawToInt(r.ReadValue64(p.size), p.size, p.signed) } } - // C# never validates that the blob is fully consumed; neither do we. + // The blob is deliberately not validated to be fully consumed. return values, nil } -// toInt32Checked mirrors Convert.ChangeType(value, typeof(int)): value- -// preserving, overflow-checked. +// toInt32Checked converts to the int32 range: value-preserving, +// overflow-checked. func toInt32Checked(v any) (int64, error) { switch x := v.(type) { case int64: diff --git a/tools/db2tool/wdc/row.go b/tools/db2tool/wdc/row.go index eb62778078..3120688eaa 100644 --- a/tools/db2tool/wdc/row.go +++ b/tools/db2tool/wdc/row.go @@ -1,7 +1,6 @@ -// Go translation of DBCD.IO's WDC4Row (the row class WDC5Reader actually -// instantiates) plus the DBCDBuilder DBD-to-field-type mapping and the -// BaseReader copy-row semantics (https://github.com/wowdev/DBCD, v2.1.2, -// commit 2180edb4d08b3822b3cfa964293ba8ccd4236ac0). +// Go translation of DBCD.IO's row decoding, DBD-to-field-type mapping and +// copy-row semantics (https://github.com/wowdev/DBCD, v2.1.2, commit +// 2180edb4d08b3822b3cfa964293ba8ccd4236ac0). // Copyright (c) 2020 wowdev. MIT License — see tools/db2tool/NOTICES.md. package wdc @@ -21,8 +20,7 @@ const ( kindString // string, or locstring when locStringSize == 1 (always true for MoP builds) ) -// fieldPlan is the precomputed per-definition decode plan, mirroring what -// DBCDBuilder encodes into the dynamic type's fields. +// fieldPlan is the precomputed per-definition decode plan. type fieldPlan struct { name string kind colKind @@ -33,21 +31,20 @@ type fieldPlan struct { isNonInlineID bool isID bool - // FieldCache.MetaDataFieldType view, used only by the hotfix decoder: - // a non-inline relation is read from a hotfix blob at its DBD-declared - // type (then Convert.ChangeType'd to int), while kind/size/signed above - // carry the typeof(int) override. Identical to kind/size/signed for - // every other field. + // The DBD-declared view, used only by the hotfix decoder: a non-inline + // relation is read from a hotfix blob at its DBD-declared type (then + // converted to int), while kind/size/signed above carry the int32 + // override. Identical to kind/size/signed for every other field. hfKind colKind hfSize int hfSigned bool } // Row is one decoded record; Values align 1:1 with Decoded.ColumnNames. -// Value dynamic types (chosen so encoding/json output matches what C# -// System.Text.Json emits for a value declared as Array — boxed numeric -// elements, no byte[]→base64 special case): int64/uint64 scalars, float32, -// string, []int64, []uint64, []float32, []string. +// Value dynamic types are limited to int64/uint64 scalars, float32, string, +// []int64, []uint64, []float32 and []string — never []byte, so encoding/json +// writes every array element-by-element as numbers (no base64), the +// wowsims.db array-text format. type Row struct { ID int32 Values []any @@ -55,13 +52,13 @@ type Row struct { type Decoded struct { ColumnNames []string - Rows []Row // ascending ID (Storage is a SortedDictionary) + Rows []Row // ascending ID } func buildFieldPlans(def dbd.DBDefinition, version dbd.VersionDefinitions, buildNumber uint32) ([]fieldPlan, error) { - // DBCDBuilder.GetLocStringSize: 1 for post-wotlk (expansion >= 4 || build > - // 12340) — always the case for the builds this tool targets. A locstring - // therefore maps to a single string field with no _mask column. + // The locstring size is 1 for post-wotlk builds (build > 12340) — always + // the case for the builds this tool targets. A locstring therefore maps + // to a single string field with no _mask column. if buildNumber <= 12340 { return nil, fmt.Errorf("build %d predates single-locale locstrings; this port only supports locStringSize == 1", buildNumber) } @@ -96,11 +93,11 @@ func buildFieldPlans(def dbd.DBDefinition, version dbd.VersionDefinitions, build default: return nil, fmt.Errorf("column %q: unable to construct field type from %q", d.Name, col.Type) } - // Capture the DBD-declared mapping (MetaDataFieldType) before the - // non-inline-relation override — the hotfix decoder reads that type. + // Capture the DBD-declared mapping before the non-inline-relation + // override — the hotfix decoder reads that type. p.hfKind, p.hfSize, p.hfSigned = p.kind, p.size, p.signed - // DBCDBuilder: a non-inline relation is always typeof(int), regardless - // of the DBD-declared type. + // A non-inline relation always decodes as a signed 32-bit int, + // regardless of the DBD-declared type. if p.isNonInlineRel { p.kind = kindInt p.size = 32 @@ -142,12 +139,12 @@ func (t *Table) DecodeRows(def dbd.DBDefinition, version dbd.VersionDefinitions, } // Copy-table rows: clone the source row's decoded values and rewrite the - // id field (BaseReader.GetCopyRows + WDC4Row.GetFields on the clone). + // id field. if len(t.copyData) > 0 { if hadInlineID { - // C# re-decodes clones with an off-by-one field mapping in this - // case; it never occurs on real data. Refuse rather than diverge. - return nil, fmt.Errorf("copy table present on a table with inline ids — unsupported (would diverge from C# behavior)") + // Never occurs on real data, and the id-field mapping would be + // ambiguous; refuse rather than guess. + return nil, fmt.Errorf("copy table present on a table with inline ids — unsupported") } idFieldIndex := int(t.IdFieldIndex) if idFieldIndex >= len(plans) { @@ -181,7 +178,7 @@ func (t *Table) DecodeRows(def dbd.DBDefinition, version dbd.VersionDefinitions, return decoded, nil } -// decodeRow ports WDC4Row.GetFields. +// decodeRow decodes one raw record into values aligned with plans. func (t *Table) decodeRow(row rawRow, plans []fieldPlan) (int32, []any, error) { r := row.data r.Position = row.dataPos @@ -233,8 +230,8 @@ func (t *Table) readScalarField(id int32, r *bitReader, fieldIndex int, p fieldP if t.Flags&flagSparse != 0 { return r.ReadCString(), nil } - // getStringTableRecord: the byte position is captured BEFORE the - // relative offset is read (C# left-to-right evaluation). + // The byte position is captured BEFORE the relative offset is read — + // the offset is relative to the field's own position. recordOffset := (int(row.recordIndex) * int(t.RecordSize)) - (int(t.RecordsCount) * int(t.RecordSize)) bytePos := r.Position >> 3 raw, err := t.getFieldRaw(id, r, fieldIndex) @@ -263,8 +260,8 @@ func (t *Table) readScalarField(id int32, r *bitReader, fieldIndex int, p fieldP } // rawToInt reinterprets the low bits of the 64-bit read per the DBD-declared -// width and signedness (Value64.GetValue semantics). Unsigned 64-bit stays -// uint64; every other case fits int64. +// width and signedness. Unsigned 64-bit stays uint64; every other case fits +// int64. func rawToInt(raw uint64, size int, signed bool) any { switch size { case 8: @@ -297,8 +294,7 @@ func (t *Table) readArrayField(r *bitReader, fieldIndex int, p fieldPlan, row ra if p.kind == kindString { if t.Flags&flagSparse != 0 { - // C# WDC4Row routes string[] to GetFieldValueStringArray, which has - // no sparse path; no configured table has string arrays. + // No configured table has string arrays in a sparse table. return nil, fmt.Errorf("string arrays in sparse tables are not supported") } if cm.CompressionType != compressionNone { @@ -368,12 +364,10 @@ func (t *Table) readArrayField(r *bitReader, fieldIndex int, p fieldPlan, row ra return out, nil } - // C# serializes these through SqliteDataInserter's `value is Array arr → - // JsonSerializer.Serialize(arr)`, whose declared type is Array: STJ takes - // the IEnumerable path and writes each element as a boxed number. That - // means byte[] serializes as [0,0,0] here, NOT base64 (verified against - // the reference DB), so plain numeric slices reproduce the text exactly. - // Width/sign truncation still follows the DBD-declared element type. + // Byte-sized arrays must stay plain numeric slices (never []byte): the + // wowsims.db array-text format is element-by-element numbers, e.g. + // [0,0,0], NOT base64. Width/sign truncation still follows the + // DBD-declared element type. if p.size == 64 && !p.signed { out := make([]uint64, len(raws)) copy(out, raws) @@ -386,7 +380,7 @@ func (t *Table) readArrayField(r *bitReader, fieldIndex int, p fieldPlan, row ra return out, nil } -// getFieldRaw ports GetFieldValue's compression dispatch, returning the +// getFieldRaw dispatches on the column's compression type, returning the // raw 64-bit value before type reinterpretation. func (t *Table) getFieldRaw(id int32, r *bitReader, fieldIndex int) (uint64, error) { fm := t.Meta[fieldIndex] diff --git a/tools/db2tool/wdc/wdc5.go b/tools/db2tool/wdc/wdc5.go index 2d0c530ed2..5fb815f30d 100644 --- a/tools/db2tool/wdc/wdc5.go +++ b/tools/db2tool/wdc/wdc5.go @@ -1,6 +1,6 @@ // Go translation of DBCD.IO's WDC5Reader (https://github.com/wowdev/DBCD, // v2.1.2, commit 2180edb4d08b3822b3cfa964293ba8ccd4236ac0), including its -// encrypted-section skip path (no TACT keys — plan §7 C1). +// encrypted-section skip path (no TACT keys). // Copyright (c) 2020 wowdev. MIT License — see tools/db2tool/NOTICES.md. package wdc @@ -36,7 +36,7 @@ type fieldMeta struct { Offset int16 } -// columnMeta mirrors ColumnMetaData; A/B/C are the 12-byte union: +// columnMeta's A/B/C are the 12-byte union interpreted per CompressionType: // Immediate{BitOffset,BitWidth,Flags} / Pallet{BitOffset,BitWidth,Cardinality} / // Common{DefaultValue,B,C}. type columnMeta struct { @@ -65,7 +65,7 @@ type sparseEntry struct { } // rawRow is a not-yet-decoded record: a bit reader positioned at its data, -// plus the identity WDC4Row captures at construction. +// plus the row's identity captured at construction. type rawRow struct { data *bitReader dataOffset int @@ -159,7 +159,7 @@ func (c *cursor) u64() (uint64, error) { } // ReadFile parses a WDC5 .db2 file. Only WDC5 is supported; anything else -// (including WDC6+) fails loud, matching the plan's format stance. +// (including WDC6+) fails loud. func ReadFile(path string) (*Table, error) { buf, err := os.ReadFile(path) if err != nil { @@ -236,11 +236,10 @@ func read(buf []byte) (*Table, error) { } } - // C# BinaryReader.ReadBytes tolerates short reads, which matters for the - // empty ItemBonus.db2: its file ends mid-way through the meta blocks, and - // the early return below never consumes them. Mirror that tolerance only - // when the early return will be taken; otherwise a truncated file is - // corrupt and must fail loud. + // The empty ItemBonus.db2 ends mid-way through the meta blocks, and the + // early return below never consumes them. Tolerate short reads only when + // the early return will be taken; otherwise a truncated file is corrupt + // and must fail loud. emptyTable := sectionsCount == 0 || t.RecordsCount == 0 t.Meta = make([]fieldMeta, t.FieldsCount) @@ -287,7 +286,7 @@ func read(buf []byte) (*Table, error) { } } - // ItemBonus.db2 is empty: 0 sections / 0 records is valid (plan §7 C2). + // ItemBonus.db2 is empty: 0 sections / 0 records is valid. if emptyTable { return t, nil } @@ -330,8 +329,8 @@ func read(buf []byte) (*Table, error) { } } - // encrypted ID lists (read sequentially; content unused, like upstream's - // m_encryptedIDs which this tool never consults) + // encrypted ID lists (read sequentially; content unused — this tool + // never consults them) for i := 0; i < sectionsCount; i++ { if t.Sections[i].TactKeyLookup == 0 { continue @@ -378,8 +377,8 @@ func read(buf []byte) (*Table, error) { } } - // Skip encrypted sections: TACT key lookup set + record data zero-filled - // (plan §7 C1). The trailing guards mirror WDC5Reader exactly. + // Skip encrypted sections: TACT key lookup set + record data + // zero-filled, unless the trailing guards below find live id data. if section.TactKeyLookup != 0 && allZero(recordsData) { completelyZero := false if section.IndexDataSize > 0 || section.CopyTableCount > 0 { @@ -542,8 +541,8 @@ func readSparseIndexData(c *cursor, section sectionHeader, indexData []int32) ([ return sparseIndexData, nil } -// readStringTable ports Extensions.ReadStringTable: NUL-separated UTF-8 -// strings keyed by byte offset (baseOffset + running offset). +// readStringTable reads NUL-separated UTF-8 strings keyed by byte offset +// (baseOffset + running offset). func readStringTable(dst map[int64]string, data []byte, baseOffset int64) { if len(data) == 0 { return diff --git a/tools/db2tool/wdc/wdc5_test.go b/tools/db2tool/wdc/wdc5_test.go index 1e09a56422..44be5df6c8 100644 --- a/tools/db2tool/wdc/wdc5_test.go +++ b/tools/db2tool/wdc/wdc5_test.go @@ -9,11 +9,10 @@ import ( "github.com/wowsims/mop/tools/db2tool/dbd" ) -// Pre-port audit fixture (plan §8 step 2), frozen from the build-68571 -// snapshot in tools/DB2ToSqlite/dbfilesclient. Tests skip when the gitignored -// snapshot is absent. -const db2Dir = "../../DB2ToSqlite/dbfilesclient" -const dbdDir = "../../DB2ToSqlite/DBDCache" +// Decoder test fixtures, frozen from the gitignored build-68571 .db2 +// snapshot. Tests skip when the snapshot is absent. +const db2Dir = "../refs/dbfilesclient" +const dbdDir = "../refs/DBDCache" const snapshotBuild = 68571 func db2Files(t *testing.T) []string { @@ -61,7 +60,7 @@ func TestParseAllHeaders(t *testing.T) { t.Errorf("%s: unexpected SecondaryKey flag", base) } } - // Distinct section counts frozen in the plan (§8 step 2), plus 0 for the + // Distinct section counts frozen from the snapshot, plus 0 for the // empty ItemBonus and 1 for plain single-section tables. for _, want := range []int{36, 33, 26, 22, 16, 9, 8, 3, 2, 1, 0} { if !sectionCounts[want] { From 84f75bc5541b5808aeb1b6e8887baf432384a56d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Hillerstr=C3=B6m?= Date: Sat, 25 Jul 2026 19:09:13 +0200 Subject: [PATCH 8/8] =?UTF-8?q?db2tool:=20review=20follow-up=20=E2=80=94?= =?UTF-8?q?=20fixes,=20dead=20code,=20and=20runnable=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - WriteBinaryAndJson only skipped writing when db.json was unchanged, so a deleted or corrupt db.bin was never regenerated and make db still exited 0. Require the binary to exist before skipping. WriteBinary/WriteJson now fail loudly instead of discarding os.WriteFile's error, which that guard relies on. - DecodeRows clones a copy-table row's decoded values, which only matches upstream while no column is COMMON-compressed: common values are resolved by row id, so a copy row would inherit the source id's value. No table has both today; error out instead of silently emitting wrong rows if that changes. - Bound the .dbd and listfile HTTP requests. The listfile deliberately bounds connect and response-header time only, since a legitimate ~150 MB transfer must not be cut off mid-flight. - Warn when --dbcache is given but no pinned file holds the extracted build, instead of quietly running without the hotfix overlay. - --build now takes a bare number or a full version string and no longer leaks an internally synthesized "0.0.0."+v into its error message. Unused code: bitReader.clone, the value32 accessors, Build.BuildConfig/ CDNConfig plus the CDN config read the local-only reader never needed, AvailableBuild.CDNPath/CDNConfig, five unread WDC5 header fields (the cursor still reads past them), and Decoded.ColumnNames, which had become a second source of truth for column order next to the version definitions. Settings files and their bindings now carry only the keys that are read. Tests: the golden gates diffed against a .NET reference capture, and the .NET tool is gone, so that reference can never be regenerated again. Together with the build-68571 fixtures under a gitignored refs/, 8 of 9 tests could only ever skip. Replaced with 21 fixture-free tests that always run: exhaustive unaligned bit-reader reads against an independent reference implementation, a synthetic XFTH v9 builder covering parse/dedup/rejection paths, SStrHash pinned to two known table hashes, and a .dbd parser suite built on an inline definition. Also fixes the sqlite test's all-zero-array assertion, which was reading the row it had just upserted. Idiom: license headers no longer sit in the package-doc slot (godoc showed concatenated license text), one package doc per package, range-over-int, strings.SplitSeq, slices.Sort/Equal, and max where gopls' modernize analyzer flagged them. Verified: extracting build 68806 with the live DBCache.bin reproduces the committed wowsims.db exactly — 236 schema objects identical, 0 differing rows across all 72 tables. --- .gitignore | 1 - tools/database/database.go | 19 +- tools/database/gen_db/main.go | 4 +- tools/database/gen_protos.go | 2 +- tools/database/generator-settings.json | 4 - tools/database/icon_loader.go | 5 + tools/database/ptr-generator-settings.json | 4 - tools/database/tables.go | 2 +- tools/db2tool/config/config.go | 19 +- tools/db2tool/dbd/dbd.go | 37 +-- tools/db2tool/dbd/dbd_test.go | 282 +++++++++++++++------ tools/db2tool/dbd/fetch.go | 7 +- tools/db2tool/dbd/select.go | 1 + tools/db2tool/golden_test.go | 189 -------------- tools/db2tool/hotfix_golden_test.go | 164 ------------ tools/db2tool/internal/golden/golden.go | 166 ------------ tools/db2tool/main.go | 20 +- tools/db2tool/sqlite/insert.go | 1 + tools/db2tool/sqlite/schema.go | 4 +- tools/db2tool/sqlite/sqlite_test.go | 30 ++- tools/db2tool/tact/blte.go | 5 +- tools/db2tool/tact/build.go | 15 +- tools/db2tool/tact/buildinfo.go | 9 +- tools/db2tool/tact/cascidx.go | 1 + tools/db2tool/tact/config.go | 3 +- tools/db2tool/tact/encoding.go | 1 + tools/db2tool/tact/fdid.go | 1 + tools/db2tool/tact/listfile.go | 23 +- tools/db2tool/tact/root.go | 3 +- tools/db2tool/wdc/bitreader.go | 13 +- tools/db2tool/wdc/bitreader_test.go | 166 ++++++++++++ tools/db2tool/wdc/hotfix.go | 4 +- tools/db2tool/wdc/hotfix_test.go | 170 +++++++++++++ tools/db2tool/wdc/row.go | 39 +-- tools/db2tool/wdc/wdc5.go | 40 +-- tools/db2tool/wdc/wdc5_test.go | 122 --------- 36 files changed, 711 insertions(+), 865 deletions(-) delete mode 100644 tools/db2tool/golden_test.go delete mode 100644 tools/db2tool/hotfix_golden_test.go delete mode 100644 tools/db2tool/internal/golden/golden.go create mode 100644 tools/db2tool/wdc/bitreader_test.go create mode 100644 tools/db2tool/wdc/hotfix_test.go delete mode 100644 tools/db2tool/wdc/wdc5_test.go diff --git a/.gitignore b/.gitignore index 1752d98686..66936d905f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,7 +47,6 @@ mop.sln graphify-out .claude CLAUDE.md -tools/db2tool/refs/ tools/db2tool/listfile.csv tools/db2tool/DBDCache/ tools/db2tool/dbfilesclient/ diff --git a/tools/database/database.go b/tools/database/database.go index 96632b8e2e..efb48f151b 100644 --- a/tools/database/database.go +++ b/tools/database/database.go @@ -298,9 +298,16 @@ func ReadDatabaseFromJson(jsonStr string) *WowDatabase { func (db *WowDatabase) WriteBinaryAndJson(binFilePath, jsonFilePath string) { jsonBytes := db.toJsonBytes() + // The JSON covers every UIDatabase field, so unchanged JSON means unchanged + // contents — worth skipping because the binary's proto encoding is not + // byte-stable and would otherwise churn on every run. A missing binary must + // still be regenerated, though, so check for it before skipping. if existing, err := os.ReadFile(jsonFilePath); err == nil && bytes.Equal(existing, jsonBytes) { - log.Printf("No changes detected, skipping write of %s and %s", binFilePath, jsonFilePath) - return + if _, err := os.Stat(binFilePath); err == nil { + log.Printf("No changes detected, skipping write of %s and %s", binFilePath, jsonFilePath) + return + } + log.Printf("%s is missing, regenerating it", binFilePath) } db.WriteBinary(binFilePath) if err := os.WriteFile(jsonFilePath, jsonBytes, 0666); err != nil { @@ -323,11 +330,15 @@ func (db *WowDatabase) WriteBinary(binFilePath string) { if err != nil { log.Fatalf("[ERROR] Failed to marshal db: %s", err.Error()) } - os.WriteFile(binFilePath, protoBytes, 0666) + if err := os.WriteFile(binFilePath, protoBytes, 0666); err != nil { + log.Fatalf("[ERROR] Failed to write %s: %s", binFilePath, err.Error()) + } } func (db *WowDatabase) WriteJson(jsonFilePath string) { - os.WriteFile(jsonFilePath, db.toJsonBytes(), 0666) + if err := os.WriteFile(jsonFilePath, db.toJsonBytes(), 0666); err != nil { + log.Fatalf("[ERROR] Failed to write %s: %s", jsonFilePath, err.Error()) + } } // Serializes in JSON format, so we can manually inspect the contents. diff --git a/tools/database/gen_db/main.go b/tools/database/gen_db/main.go index 6529156359..c4e92d9e86 100644 --- a/tools/database/gen_db/main.go +++ b/tools/database/gen_db/main.go @@ -26,7 +26,7 @@ import ( var outDir = flag.String("outDir", "assets", "Path to output directory for writing generated .go files.") var genAsset = flag.String("gen", "", "Asset to generate. Valid values are 'db', 'atlasloot', 'wowhead-items', 'wowhead-spells', 'wowhead-itemdb', 'mop-items', and 'wago-db2-items'") -var dbPath = flag.String("dbPath", "./tools/database/wowsims.db", "Location of wowsims.db file from the DB2ToSqliteTool") +var dbPath = flag.String("dbPath", "./tools/database/wowsims.db", "Location of the wowsims.db file produced by tools/db2tool") func main() { flag.Parse() @@ -150,7 +150,7 @@ func main() { db.Encounters = core.PresetEncounters db.ReforgeStats = reforgeStats.ToProto() - iconsMap, err := database.LoadArtTexturePaths("./tools/db2tool/listfile.csv") + iconsMap, err := database.LoadArtTexturePaths(database.ListfilePath) if err != nil { panic(fmt.Sprintf("Error loading icon paths %v", err)) } diff --git a/tools/database/gen_protos.go b/tools/database/gen_protos.go index cb5348845d..caa663d5d6 100644 --- a/tools/database/gen_protos.go +++ b/tools/database/gen_protos.go @@ -455,7 +455,7 @@ func GenerateProtos(dbcData *dbc.DBC, db *WowDatabase) { allGlyphSpellIds := []*proto.GlyphID{} var classesData []ClassData - iconsMap, _ := LoadArtTexturePaths("./tools/db2tool/listfile.csv") + iconsMap, _ := LoadArtTexturePaths(ListfilePath) for _, dbcClass := range dbc.Classes { className := dbc.ClassNameFromDBC(dbcClass) data := ClassData{ diff --git a/tools/database/generator-settings.json b/tools/database/generator-settings.json index aa876ec8cd..bc0336f4f7 100644 --- a/tools/database/generator-settings.json +++ b/tools/database/generator-settings.json @@ -1,13 +1,9 @@ { "Settings": { "BaseDir": "/mnt/c/Program Files/World of Warcraft", - "BuildConfig": "buildConfig", - "CDNConfig": "cdnConfig", - "Region": "us", "Product": "wow_classic" }, "TargetDirectory": "dbfilesclient", - "DatabaseFile": "wowsims.db", "GameTablesOutDirectory": "../../assets/db_inputs/basestats", "GameTables": [ "chancetomeleecrit", diff --git a/tools/database/icon_loader.go b/tools/database/icon_loader.go index eaf29bf054..a726af1d1c 100644 --- a/tools/database/icon_loader.go +++ b/tools/database/icon_loader.go @@ -10,6 +10,11 @@ import ( "strings" ) +// ListfilePath is where tools/db2tool caches the community listfile. It maps +// file data ids to game paths and is a second output contract of the extractor, +// alongside wowsims.db. +const ListfilePath = "./tools/db2tool/listfile.csv" + func LoadArtTexturePaths(filePath string) (map[int]string, error) { f, err := os.Open(filePath) if err != nil { diff --git a/tools/database/ptr-generator-settings.json b/tools/database/ptr-generator-settings.json index b03d291e8e..979b1c97ce 100644 --- a/tools/database/ptr-generator-settings.json +++ b/tools/database/ptr-generator-settings.json @@ -1,13 +1,9 @@ { "Settings": { "BaseDir": "/mnt/c/Program Files/World of Warcraft", - "BuildConfig": "buildConfig", - "CDNConfig": "cdnConfig", - "Region": "us", "Product": "wow_classic_ptr" }, "TargetDirectory": "dbfilesclient", - "DatabaseFile": "wowsims.db", "GameTablesOutDirectory": "../../assets/db_inputs/basestats", "GameTables": [ "chancetomeleecrit", diff --git a/tools/database/tables.go b/tools/database/tables.go index 629ba25e86..506946e9db 100644 --- a/tools/database/tables.go +++ b/tools/database/tables.go @@ -1163,7 +1163,7 @@ LEFT JOIN SpellName sn ON sn.ID = sm.SpellID return iconsByID, nil } -var iconsMap, _ = LoadArtTexturePaths("./tools/db2tool/listfile.csv") +var iconsMap, _ = LoadArtTexturePaths(ListfilePath) func ScanSpells(rows *sql.Rows) (dbc.Spell, error) { var spell dbc.Spell diff --git a/tools/db2tool/config/config.go b/tools/db2tool/config/config.go index 2694fe0fc2..a92c19d6b8 100644 --- a/tools/db2tool/config/config.go +++ b/tools/db2tool/config/config.go @@ -1,5 +1,5 @@ -// Settings JSON binding for tools/db2tool (generator-settings.json / -// ptr-generator-settings.json). +// Package config binds the db2tool settings files, tools/database/ +// generator-settings.json and ptr-generator-settings.json. package config import ( @@ -8,17 +8,11 @@ import ( "os" ) -// Settings is the settings file's "Settings" section. Only the fields the -// tool actually consumes are used today; the rest are bound so existing -// settings files parse cleanly (CacheDir and Locale are bound-but-unused). +// Settings is the settings file's "Settings" section: which product to extract +// and the install to read it from. type Settings struct { - Region string `json:"Region"` - Product string `json:"Product"` - BaseDir string `json:"BaseDir"` - BuildConfig string `json:"BuildConfig"` - CDNConfig string `json:"CDNConfig"` - CacheDir string `json:"CacheDir"` - Locale string `json:"Locale"` + Product string `json:"Product"` + BaseDir string `json:"BaseDir"` } type File struct { @@ -27,7 +21,6 @@ type File struct { // the FDID/listfile use must always see the raw value, never a // filesystem-resolved path. TargetDirectory string `json:"TargetDirectory"` - DatabaseFile string `json:"DatabaseFile"` // bound, never read — the --output flag decides the path GameTablesOutDirectory string `json:"GameTablesOutDirectory"` GameTables []string `json:"GameTables"` Tables []string `json:"Tables"` diff --git a/tools/db2tool/dbd/dbd.go b/tools/db2tool/dbd/dbd.go index 6a83a5cbd1..d85a4e4bee 100644 --- a/tools/db2tool/dbd/dbd.go +++ b/tools/db2tool/dbd/dbd.go @@ -3,12 +3,16 @@ // Copyright 2022 WoWDBDefs Contributors. Licensed under BSD-3-Clause; this // file remains BSD-3-Clause (full text, including the non-endorsement clause, // in tools/db2tool/NOTICES.md). Upstream commit 9002c532853a96d631c76dda50cb20189c27a173. + +// Package dbd parses WoWDBDefs .dbd definition files and selects the version +// block matching an exact build number. package dbd import ( "fmt" "io" "os" + "slices" "strconv" "strings" ) @@ -257,7 +261,7 @@ func Read(r io.Reader, validate bool) (DBDefinition, error) { } if strings.HasPrefix(line, "BUILD") { - for _, splitBuild := range strings.Split(line[6:], ", ") { + for splitBuild := range strings.SplitSeq(line[6:], ", ") { if strings.Contains(splitBuild, "-") { splitRange := strings.Split(splitBuild, "-") minBuild, err := ParseBuild(splitRange[0]) @@ -293,8 +297,7 @@ func Read(r io.Reader, validate bool) (DBDefinition, error) { if annotationEnd < 0 { return DBDefinition{}, fmt.Errorf("unterminated annotation on line %q", line) } - annotations := strings.Split(line[annotationStart+1:annotationEnd], ",") - for _, a := range annotations { + for a := range strings.SplitSeq(line[annotationStart+1:annotationEnd], ",") { switch a { case "id": definition.IsID = true @@ -470,9 +473,9 @@ func runValidation(columnDefinitions map[string]ColumnDefinition, versionDefinit } } - if definitionsEqual(versionDefinitions[i].Definitions, versionDefinitions[j].Definitions) { + if slices.Equal(versionDefinitions[i].Definitions, versionDefinitions[j].Definitions) { if len(versionDefinitions[i].LayoutHashes) > 0 && len(versionDefinitions[j].LayoutHashes) > 0 && - !stringSlicesEqual(versionDefinitions[i].LayoutHashes, versionDefinitions[j].LayoutHashes) { + !slices.Equal(versionDefinitions[i].LayoutHashes, versionDefinitions[j].LayoutHashes) { // Upstream ignores this case (identical definitions, different layout hashes). } else { return fmt.Errorf("dbd file has 2 identical version definitions (%d and %d)", i+1, j+1) @@ -484,30 +487,6 @@ func runValidation(columnDefinitions map[string]ColumnDefinition, versionDefinit return nil } -func definitionsEqual(a, b []Definition) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -func stringSlicesEqual(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - // readLines splits raw file bytes into lines: \r\n, \r, and \n all terminate // a line, a terminator at EOF does not produce a trailing empty line, and a // leading UTF-8 BOM is stripped. diff --git a/tools/db2tool/dbd/dbd_test.go b/tools/db2tool/dbd/dbd_test.go index 62c665e648..ca5d8edc3e 100644 --- a/tools/db2tool/dbd/dbd_test.go +++ b/tools/db2tool/dbd/dbd_test.go @@ -1,127 +1,247 @@ package dbd import ( - "os" - "path/filepath" "strings" "testing" ) -// Fixture values are frozen from the gitignored build-68571 .dbd snapshot. -// The tests skip when the snapshot is absent (e.g. CI). -const snapshotBuild = 68571 +// A .dbd covering every construct the 72 configured tables actually use: +// plain/foreign-key/unverified columns, all five types, $id$ / $noninline,id$ / +// $relation$ annotations, signed and unsigned suffixes, [n] arrays, +// LAYOUT + single builds + a build range, and a COMMENT. +const sampleDBD = `COLUMNS +int ID +int ItemID +uint Flags +float Coefficient +string Path +locstring Name +int Unverified? +int Legacy -const dbdCacheDir = "../refs/DBDCache" +LAYOUT 0A1B2C3D +BUILD 5.5.0.60000 +COMMENT older layout +$id$ID<32> +Legacy<16> +Coefficient +Name -func snapshotFiles(t *testing.T) []string { +LAYOUT 4E5F6071, 8899AABB +BUILD 5.5.4.68571, 5.5.4.68806 +BUILD 5.4.0.10000-5.4.9.19999 +$noninline,id$ID<32> +$relation$ItemID<32> +Flags[3] +Coefficient +Path +Name +Unverified<32> +` + +func parseSample(t *testing.T) DBDefinition { t.Helper() - entries, err := os.ReadDir(dbdCacheDir) - if os.IsNotExist(err) { - t.Skipf("%s not present (gitignored snapshot); skipping", dbdCacheDir) - } + def, err := Read(strings.NewReader(sampleDBD), true) if err != nil { t.Fatal(err) } - var files []string - for _, e := range entries { - if strings.HasSuffix(e.Name(), ".dbd") { - files = append(files, filepath.Join(dbdCacheDir, e.Name())) - } + return def +} + +func TestReadColumnDefinitions(t *testing.T) { + def := parseSample(t) + + if got := def.ColumnDefinitions["ID"].Type; got != "int" { + t.Errorf("ID type = %q, want int", got) } - if len(files) != 72 { - t.Fatalf("expected 72 .dbd files in snapshot, got %d", len(files)) + if got := def.ColumnDefinitions["Flags"].Type; got != "uint" { + t.Errorf("Flags type = %q, want uint", got) + } + if got := def.ColumnDefinitions["Name"].Type; got != "locstring" { + t.Errorf("Name type = %q, want locstring", got) } - return files -} -func TestParseSnapshotAndSelect68571(t *testing.T) { - typeCounts := map[string]int{} - for _, file := range snapshotFiles(t) { - def, err := ReadFile(file, true) - if err != nil { - t.Fatalf("parse %s: %v", file, err) - } - version, err := SelectVersion(def, snapshotBuild) - if err != nil { - t.Fatalf("select %d in %s: %v", snapshotBuild, file, err) - } - if len(version.Definitions) == 0 { - t.Fatalf("%s: selected version has no definitions", file) - } - for _, d := range version.Definitions { - col, ok := def.ColumnDefinitions[d.Name] - if !ok { - t.Fatalf("%s: definition %q missing from COLUMNS", file, d.Name) - } - typeCounts[col.Type]++ - } + // Foreign keys drive both the NULL-ability and the IX_ index in the schema. + item := def.ColumnDefinitions["ItemID"] + if item.ForeignTable != "Item" || item.ForeignColumn != "ID" { + t.Errorf("ItemID foreign key = %q::%q, want Item::ID", item.ForeignTable, item.ForeignColumn) + } + if def.ColumnDefinitions["ID"].ForeignTable != "" { + t.Error("ID must not have a foreign key") } - // Frozen per-build totals for the selected 68571 blocks. - want := map[string]int{"int": 515, "float": 65, "locstring": 43, "string": 5} - for typ, n := range want { - if typeCounts[typ] != n { - t.Errorf("type %s: got %d definitions, want %d", typ, typeCounts[typ], n) - } + // A trailing ? marks the column unverified and must not survive in the name. + if !def.ColumnDefinitions["ID"].Verified { + t.Error("ID should be verified") } - if typeCounts["uint"] != 0 { - t.Errorf("expected no uint columns in selected blocks, got %d", typeCounts["uint"]) + if u, ok := def.ColumnDefinitions["Unverified"]; !ok || u.Verified { + t.Errorf("Unverified column = %+v, ok=%v; want present and unverified", u, ok) } } -func TestItemRandomSuffixShape(t *testing.T) { - if _, err := os.Stat(dbdCacheDir); os.IsNotExist(err) { - t.Skipf("%s not present; skipping", dbdCacheDir) +func TestReadVersionDefinitions(t *testing.T) { + def := parseSample(t) + + if len(def.VersionDefinitions) != 2 { + t.Fatalf("parsed %d version blocks, want 2", len(def.VersionDefinitions)) } - def, err := ReadFile(filepath.Join(dbdCacheDir, "ItemRandomSuffix.dbd"), true) - if err != nil { - t.Fatal(err) + old, cur := def.VersionDefinitions[0], def.VersionDefinitions[1] + + if len(old.Builds) != 1 || old.Builds[0].Build != 60000 { + t.Errorf("first block builds = %v, want one 60000", old.Builds) } - version, err := SelectVersion(def, snapshotBuild) - if err != nil { - t.Fatal(err) + if old.Comment != "older layout" { + t.Errorf("first block comment = %q", old.Comment) + } + if len(old.LayoutHashes) != 1 || old.LayoutHashes[0] != "0A1B2C3D" { + t.Errorf("first block layout hashes = %v", old.LayoutHashes) + } + + if len(cur.Builds) != 2 || cur.Builds[0].Build != 68571 || cur.Builds[1].Build != 68806 { + t.Errorf("second block builds = %v, want 68571 and 68806", cur.Builds) } + if len(cur.BuildRanges) != 1 { + t.Fatalf("second block build ranges = %v, want 1", cur.BuildRanges) + } + if got := cur.BuildRanges[0].String(); got != "5.4.0.10000-5.4.9.19999" { + t.Errorf("build range = %q", got) + } + if len(cur.LayoutHashes) != 2 { + t.Errorf("second block layout hashes = %v, want 2", cur.LayoutHashes) + } +} +func TestReadDefinitionAnnotations(t *testing.T) { + def := parseSample(t) byName := map[string]Definition{} - for _, d := range version.Definitions { + for _, d := range def.VersionDefinitions[1].Definitions { byName[d.Name] = d } - // §5.5: the 68571 block must be the <32>[5] one, not the legacy [3]. - if got := byName["AllocationPct"].ArrLength; got != 5 { - t.Errorf("AllocationPct arrLength = %d, want 5", got) + // $noninline,id$ — the id lives in the index block, not the record. + id := byName["ID"] + if !id.IsID || !id.IsNonInline || id.Size != 32 || !id.IsSigned { + t.Errorf("ID = %+v, want id + noninline + signed size 32", id) } - if got := byName["Enchantment"].ArrLength; got != 5 { - t.Errorf("Enchantment arrLength = %d, want 5", got) + // An inline $id$ must NOT be flagged non-inline. + oldID := def.VersionDefinitions[0].Definitions[0] + if !oldID.IsID || oldID.IsNonInline { + t.Errorf("first-block ID = %+v, want id and inline", oldID) } - id := byName["ID"] - if !id.IsID || !id.IsNonInline || id.Size != 32 { - t.Errorf("ID definition = %+v, want isID + noninline + size 32", id) + + rel := byName["ItemID"] + if !rel.IsRelation || rel.IsNonInline { + t.Errorf("ItemID = %+v, want relation and inline", rel) + } + + // [3]: unsigned, 8-bit, three elements. + flags := byName["Flags"] + if flags.Size != 8 || flags.IsSigned || flags.ArrLength != 3 { + t.Errorf("Flags = %+v, want unsigned size 8 arrLength 3", flags) + } + + // float/string/locstring carry no size, and non-arrays report 0. + for _, name := range []string{"Coefficient", "Path", "Name"} { + if d := byName[name]; d.Size != 0 || d.ArrLength != 0 { + t.Errorf("%s = %+v, want size 0 arrLength 0", name, d) + } } } -func TestItemSparseBuildSuffixedField(t *testing.T) { - if _, err := os.Stat(dbdCacheDir); os.IsNotExist(err) { - t.Skipf("%s not present; skipping", dbdCacheDir) +func TestSelectVersionExactBuildOnly(t *testing.T) { + def := parseSample(t) + + for _, build := range []uint32{68571, 68806} { + v, err := SelectVersion(def, build) + if err != nil { + t.Fatalf("build %d: %v", build, err) + } + if len(v.Definitions) != 7 { + t.Errorf("build %d selected %d definitions, want the 7-column block", build, len(v.Definitions)) + } } - def, err := ReadFile(filepath.Join(dbdCacheDir, "ItemSparse.dbd"), true) + + v, err := SelectVersion(def, 60000) if err != nil { t.Fatal(err) } - version, err := SelectVersion(def, snapshotBuild) + if len(v.Definitions) != 4 { + t.Errorf("build 60000 selected %d definitions, want the 4-column block", len(v.Definitions)) + } + + // A build covered only by a buildRange is deliberately NOT matched: an + // unlisted live build must fail loud rather than decode with a near-miss + // layout. + if _, err := SelectVersion(def, 15000); err == nil { + t.Error("a build inside a buildRange must not be selected") + } + if _, err := SelectVersion(def, 99999); err == nil { + t.Error("an unknown build must be rejected") + } +} + +func TestReadRejectsMalformed(t *testing.T) { + tests := map[string]string{ + "no COLUMNS header": "BUILD 1.2.3.4\nID\n", + "unknown type": "COLUMNS\nblob Data\n\nBUILD 1.2.3.4\nData\n", + "missing space": "COLUMNS\nintID\n\nBUILD 1.2.3.4\nintID\n", + "undeclared column": "COLUMNS\nint ID\n\nBUILD 1.2.3.4\nMissing<32>\n", + "int without size": "COLUMNS\nint ID\n\nBUILD 1.2.3.4\nID\n", + "size on a string": "COLUMNS\nstring S\n\nBUILD 1.2.3.4\nS<32>\n", + "bad build string": "COLUMNS\nint ID\n\nBUILD notabuild\nID<32>\n", + "empty file": "", + } + for name, src := range tests { + if _, err := Read(strings.NewReader(src), true); err == nil { + t.Errorf("%s: expected an error, got nil", name) + } + } +} + +func TestParseBuild(t *testing.T) { + b, err := ParseBuild("5.5.4.68571") if err != nil { t.Fatal(err) } - found := false - for _, d := range version.Definitions { - if d.Name == "Field_1_15_3_55112_014" { - found = true - if d.ArrLength != 10 { - t.Errorf("Field_1_15_3_55112_014 arrLength = %d, want 10", d.ArrLength) - } + if b.Expansion != 5 || b.Major != 5 || b.Minor != 4 || b.Build != 68571 { + t.Errorf("ParseBuild = %+v", b) + } + if got := b.String(); got != "5.5.4.68571" { + t.Errorf("String() = %q", got) + } + for _, bad := range []string{"5.5.4", "5.5.4.68571.1", "", "a.b.c.d", "5.5.4.x"} { + if _, err := ParseBuild(bad); err == nil { + t.Errorf("ParseBuild(%q) should have failed", bad) + } + } +} + +func TestBuildCompareAndRange(t *testing.T) { + mustParse := func(s string) Build { + t.Helper() + b, err := ParseBuild(s) + if err != nil { + t.Fatal(err) + } + return b + } + lo, hi := mustParse("5.4.0.10000"), mustParse("5.4.9.19999") + r := BuildRange{MinBuild: lo, MaxBuild: hi} + + for _, in := range []string{"5.4.0.10000", "5.4.5.15000", "5.4.9.19999"} { + if !r.Contains(mustParse(in)) { + t.Errorf("%s should be inside %s", in, r) + } + } + for _, out := range []string{"5.3.9.9999", "5.5.0.10001", "5.4.0.9999"} { + if r.Contains(mustParse(out)) { + t.Errorf("%s should be outside %s", out, r) } } - if !found { - t.Error("Field_1_15_3_55112_014 not present in selected ItemSparse block") + if mustParse("5.5.4.68571").Compare(mustParse("5.5.4.68806")) >= 0 { + t.Error("68571 should compare less than 68806") + } + if mustParse("5.5.4.68571").Compare(mustParse("5.5.4.68571")) != 0 { + t.Error("identical builds should compare equal") } } diff --git a/tools/db2tool/dbd/fetch.go b/tools/db2tool/dbd/fetch.go index 756aa0204a..309e7cf686 100644 --- a/tools/db2tool/dbd/fetch.go +++ b/tools/db2tool/dbd/fetch.go @@ -2,6 +2,7 @@ // gitignored cache directory with a 24h-mtime freshness rule. The .dbd files // themselves are CC BY-SA 4.0 DATA and are deliberately cached, never // vendored. + package dbd import ( @@ -15,6 +16,10 @@ import ( const dbdURLFormat = "https://raw.githubusercontent.com/wowdev/WoWDBDefs/master/definitions/%s.dbd" +// httpClient bounds the fetch so a stalled connection cannot hang make db +// indefinitely; the fallback to a cached copy handles the timeout. +var httpClient = &http.Client{Timeout: 60 * time.Second} + // FetchCached returns the path to a cached .dbd for tableName under cacheDir, // fetching from WoWDBDefs when the cached copy is absent or older than 24h. // On a failed refresh of an existing copy, the stale copy is used; a missing @@ -40,7 +45,7 @@ func FetchCached(cacheDir, tableName string) (string, error) { } func download(url, path string) error { - resp, err := http.Get(url) + resp, err := httpClient.Get(url) if err != nil { return err } diff --git a/tools/db2tool/dbd/select.go b/tools/db2tool/dbd/select.go index 03bf1f8bfb..7a3a9875e5 100644 --- a/tools/db2tool/dbd/select.go +++ b/tools/db2tool/dbd/select.go @@ -1,6 +1,7 @@ // Version selection for .dbd definitions. // Derived from DBDefsLib types (https://github.com/wowdev/WoWDBDefs). // Copyright 2022 WoWDBDefs Contributors. BSD-3-Clause — see tools/db2tool/NOTICES.md. + package dbd import "fmt" diff --git a/tools/db2tool/golden_test.go b/tools/db2tool/golden_test.go deleted file mode 100644 index 75cb8a8daa..0000000000 --- a/tools/db2tool/golden_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package main - -import ( - "database/sql" - "os" - "path/filepath" - "testing" - - "github.com/wowsims/mop/tools/db2tool/config" - "github.com/wowsims/mop/tools/db2tool/dbd" - "github.com/wowsims/mop/tools/db2tool/internal/golden" - "github.com/wowsims/mop/tools/db2tool/sqlite" - "github.com/wowsims/mop/tools/db2tool/wdc" - _ "modernc.org/sqlite" -) - -// Golden gate: builds a wowsims.db from the pre-extracted build-68571 -// snapshot and diffs it against a reference database capture. -// -// - Schema parity is ALWAYS strict — hotfixes never change schema. -// - Row parity is strict when DB2TOOL_REF_DB points at a without-hotfix -// reference capture. Against the default repo reference -// (tools/database/wowsims.db, built WITH hotfixes), small per-table diffs -// are tolerated and logged: they are the hotfix overlay. A systematic -// decoder bug produces thousands of diff lines and still fails. -// -// Skips when the gitignored snapshot/reference are absent (e.g. CI). -func TestGoldenParity(t *testing.T) { - const snapshotBuild = 68571 - db2Dir := "refs/dbfilesclient" - dbdDir := "refs/DBDCache" - settingsPath := "../database/generator-settings.json" - - refPath := os.Getenv("DB2TOOL_REF_DB") - strict := refPath != "" - if refPath == "" { - refPath = "../database/wowsims.db" - } - for _, p := range []string{db2Dir, dbdDir, settingsPath, refPath} { - if _, err := os.Stat(p); os.IsNotExist(err) { - t.Skipf("%s not present; skipping golden gate", p) - } - } - - settings, err := config.Load(settingsPath) - if err != nil { - t.Fatal(err) - } - - outPath := filepath.Join(t.TempDir(), "wowsims.go.db") - goDB, err := sqlite.Open(outPath) - if err != nil { - t.Fatal(err) - } - defer goDB.Close() - - var tableDefs []sqlite.TableDef - decodedByTable := map[string]*wdc.Decoded{} - floatCols := map[string][]int{} // table -> float array definition indexes - - for _, tableName := range settings.Tables { - table, err := wdc.ReadFile(filepath.Join(db2Dir, tableName+".db2")) - if err != nil { - t.Fatal(err) - } - def, err := dbd.ReadFile(filepath.Join(dbdDir, tableName+".dbd"), true) - if err != nil { - t.Fatal(err) - } - version, err := dbd.SelectVersion(def, snapshotBuild) - if err != nil { - t.Fatalf("%s: %v", tableName, err) - } - decoded, err := table.DecodeRows(def, version, snapshotBuild) - if err != nil { - t.Fatalf("%s: %v", tableName, err) - } - tableDefs = append(tableDefs, sqlite.TableDef{Name: tableName, Def: def, Version: version}) - decodedByTable[tableName] = decoded - for i, d := range version.Definitions { - if def.ColumnDefinitions[d.Name].Type == "float" { - floatCols[tableName] = append(floatCols[tableName], i) - } - } - } - - if err := sqlite.CreateTables(goDB, tableDefs); err != nil { - t.Fatal(err) - } - for _, td := range tableDefs { - if err := sqlite.InsertRows(goDB, td, decodedByTable[td.Name]); err != nil { - t.Fatal(err) - } - } - - refDB, err := sql.Open("sqlite", refPath) - if err != nil { - t.Fatal(err) - } - defer refDB.Close() - - // 1. Schema parity — strict. - refSchema, err := golden.SchemaDDL(refDB) - if err != nil { - t.Fatal(err) - } - goSchema, err := golden.SchemaDDL(goDB) - if err != nil { - t.Fatal(err) - } - if len(refSchema) != len(goSchema) { - t.Fatalf("schema object count: ref %d vs go %d", len(refSchema), len(goSchema)) - } - for i := range refSchema { - if refSchema[i] != goSchema[i] { - t.Errorf("schema mismatch:\n ref: %s\n go: %s", refSchema[i], goSchema[i]) - } - } - - // 2. Float-notation risk: no critical-table float ARRAY element may fall - // in the divergent text-rendering ranges (golden.FloatDiverges). Scalars - // are exempt: they - // bind numerically as REAL and never go through text formatting (e.g. - // SpellEffect has ±1e17 scalar coefficients that are byte-identical in - // the reference). - critical := map[string]bool{} - for _, name := range golden.CriticalTables { - critical[name] = true - } - for tableName, cols := range floatCols { - if !critical[tableName] { - continue - } - for _, decodedRow := range decodedByTable[tableName].Rows { - for _, ci := range cols { - switch v := decodedRow.Values[ci].(type) { - case []float32: - for _, f := range v { - if golden.FloatDiverges(f) { - t.Errorf("%s row %d: float %v in divergent notation range (needs a reference-compatible formatter)", tableName, decodedRow.ID, f) - } - } - } - } - } - } - - // 3. Row parity. - const hotfixTolerance = 12 // diff lines per table vs a with-hotfix reference - totalDiff := 0 - for _, td := range tableDefs { - refRows, err := golden.DumpRows(refDB, td.Name) - if err != nil { - t.Fatalf("ref %s: %v", td.Name, err) - } - goRows, err := golden.DumpRows(goDB, td.Name) - if err != nil { - t.Fatalf("go %s: %v", td.Name, err) - } - refOnly, goOnly := golden.DiffLines(refRows, goRows) - n := len(refOnly) + len(goOnly) - totalDiff += n - if n == 0 { - continue - } - if !critical[td.Name] { - t.Logf("%s (slack): %d diff lines (informational)", td.Name, n) - continue - } - if strict || n > hotfixTolerance { - for i, l := range refOnly { - if i >= 3 { - break - } - t.Errorf("%s: ref-only row: %.200s", td.Name, l) - } - for i, l := range goOnly { - if i >= 3 { - break - } - t.Errorf("%s: go-only row: %.200s", td.Name, l) - } - t.Errorf("%s: %d row diff lines", td.Name, n) - } else { - t.Logf("%s: %d diff lines (within with-hotfix tolerance — expected hotfix-overlay deltas)", td.Name, n) - } - } - t.Logf("total row diff lines across all tables: %d", totalDiff) -} diff --git a/tools/db2tool/hotfix_golden_test.go b/tools/db2tool/hotfix_golden_test.go deleted file mode 100644 index 8ac415e036..0000000000 --- a/tools/db2tool/hotfix_golden_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package main - -import ( - "database/sql" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/wowsims/mop/tools/db2tool/config" - "github.com/wowsims/mop/tools/db2tool/dbd" - "github.com/wowsims/mop/tools/db2tool/internal/golden" - "github.com/wowsims/mop/tools/db2tool/sqlite" - "github.com/wowsims/mop/tools/db2tool/wdc" - _ "modernc.org/sqlite" -) - -// Hotfix golden gate: builds a wowsims.db from the pre-extracted build-68571 -// snapshot WITH the refs/DBCache.68571.bin hotfix overlay applied, and diffs -// it against refs/wowsims.hotfix.db — a reference capture produced with that -// same cache. Row parity is strict for every table; the only tolerated -// divergence is the documented CurvePoint Id=236585 float notation -// (reference "[1,-6E-05]" vs Go "[1,-0.00006]") — exactly one line per side. -// -// Skips when the gitignored snapshot/refs assets are absent (e.g. CI). -func TestHotfixGoldenParity(t *testing.T) { - const snapshotBuild = 68571 - db2Dir := "refs/dbfilesclient" - dbdDir := "refs/DBDCache" - settingsPath := "../database/generator-settings.json" - cachePath := "refs/DBCache.68571.bin" - refPath := "refs/wowsims.hotfix.db" - - for _, p := range []string{db2Dir, dbdDir, settingsPath, cachePath, refPath} { - if _, err := os.Stat(p); os.IsNotExist(err) { - t.Skipf("%s not present; skipping hotfix golden gate", p) - } - } - - settings, err := config.Load(settingsPath) - if err != nil { - t.Fatal(err) - } - - readers, err := wdc.CombineHotfixFiles([]string{cachePath}) - if err != nil { - t.Fatal(err) - } - reader := readers[snapshotBuild] - if reader == nil { - t.Fatalf("%s holds no build-%d hotfixes", cachePath, snapshotBuild) - } - - outPath := filepath.Join(t.TempDir(), "wowsims.go.db") - goDB, err := sqlite.Open(outPath) - if err != nil { - t.Fatal(err) - } - defer goDB.Close() - - var tableDefs []sqlite.TableDef - decodedByTable := map[string]*wdc.Decoded{} - - for _, tableName := range settings.Tables { - table, err := wdc.ReadFile(filepath.Join(db2Dir, tableName+".db2")) - if err != nil { - t.Fatal(err) - } - // SStrHash port check: the uppercased table name must hash to the - // WDC5 header TableHash the hotfix records are keyed by. - if got := wdc.SStrHash(strings.ToUpper(tableName)); got != table.TableHash { - t.Errorf("SStrHash(%q) = 0x%08X, want header TableHash 0x%08X", tableName, got, table.TableHash) - } - def, err := dbd.ReadFile(filepath.Join(dbdDir, tableName+".dbd"), true) - if err != nil { - t.Fatal(err) - } - version, err := dbd.SelectVersion(def, snapshotBuild) - if err != nil { - t.Fatalf("%s: %v", tableName, err) - } - decoded, err := table.DecodeRows(def, version, snapshotBuild) - if err != nil { - t.Fatalf("%s: %v", tableName, err) - } - if err := reader.ApplyHotfixes(table, def, version, snapshotBuild, decoded); err != nil { - t.Fatalf("%s: applying hotfixes: %v", tableName, err) - } - tableDefs = append(tableDefs, sqlite.TableDef{Name: tableName, Def: def, Version: version}) - decodedByTable[tableName] = decoded - } - - if err := sqlite.CreateTables(goDB, tableDefs); err != nil { - t.Fatal(err) - } - for _, td := range tableDefs { - if err := sqlite.InsertRows(goDB, td, decodedByTable[td.Name]); err != nil { - t.Fatal(err) - } - } - - refDB, err := sql.Open("sqlite", refPath) - if err != nil { - t.Fatal(err) - } - defer refDB.Close() - - // Schema parity — hotfixes must never change schema. - refSchema, err := golden.SchemaDDL(refDB) - if err != nil { - t.Fatal(err) - } - goSchema, err := golden.SchemaDDL(goDB) - if err != nil { - t.Fatal(err) - } - if len(refSchema) != len(goSchema) { - t.Fatalf("schema object count: ref %d vs go %d", len(refSchema), len(goSchema)) - } - for i := range refSchema { - if refSchema[i] != goSchema[i] { - t.Errorf("schema mismatch:\n ref: %s\n go: %s", refSchema[i], goSchema[i]) - } - } - - // Row parity — strict, modulo the known CurvePoint notation divergence. - totalDiff := 0 - for _, td := range tableDefs { - refRows, err := golden.DumpRows(refDB, td.Name) - if err != nil { - t.Fatalf("ref %s: %v", td.Name, err) - } - goRows, err := golden.DumpRows(goDB, td.Name) - if err != nil { - t.Fatalf("go %s: %v", td.Name, err) - } - refOnly, goOnly := golden.DiffLines(refRows, goRows) - n := len(refOnly) + len(goOnly) - totalDiff += n - if n == 0 { - continue - } - if td.Name == "CurvePoint" && len(refOnly) == 1 && len(goOnly) == 1 && - strings.Contains(refOnly[0], "|236585|") && strings.Contains(refOnly[0], "-6E-05") && - strings.Contains(goOnly[0], "|236585|") && strings.Contains(goOnly[0], "-0.00006") { - t.Logf("CurvePoint: known Id=236585 float-notation divergence (2 lines)") - continue - } - for i, l := range refOnly { - if i >= 3 { - break - } - t.Errorf("%s: ref-only row: %.200s", td.Name, l) - } - for i, l := range goOnly { - if i >= 3 { - break - } - t.Errorf("%s: go-only row: %.200s", td.Name, l) - } - t.Errorf("%s: %d row diff lines", td.Name, n) - } - t.Logf("total row diff lines across all tables: %d (2 expected: CurvePoint notation)", totalDiff) -} diff --git a/tools/db2tool/internal/golden/golden.go b/tools/db2tool/internal/golden/golden.go deleted file mode 100644 index 99f855a648..0000000000 --- a/tools/db2tool/internal/golden/golden.go +++ /dev/null @@ -1,166 +0,0 @@ -// Package golden is the validation harness for tools/db2tool: it compares a -// freshly built wowsims.db against a captured reference database — schema -// DDL, per-table row counts, and canonical row dumps. -package golden - -import ( - "database/sql" - "fmt" - "math" - "strings" -) - -// CriticalTables is the byte-exact-critical set: row + value parity -// required. Slack tables must merely extract without error. -var CriticalTables = []string{ - "Item", "ItemSparse", "SpellEffect", "SpellItemEnchantment", "ItemRandomSuffix", - "RandPropPoints", "SpellMisc", - "ItemDamageAmmo", "ItemDamageOneHand", "ItemDamageOneHandCaster", "ItemDamageRanged", - "ItemDamageThrown", "ItemDamageTwoHand", "ItemDamageTwoHandCaster", "ItemDamageWand", - "ItemArmorQuality", "ItemArmorShield", "ItemArmorTotal", "ArmorLocation", - "GemProperties", "ItemEffect", "ItemClass", "ItemSubClass", "ItemSet", - "ItemNameDescription", "RulesetItemUpgrade", "ItemUpgrade", - "Spell", "SpellName", "SpellLevels", "SpellCooldowns", "SpellScaling", "SpellLabel", - "SpellCategories", "SpellCategory", "SpellDuration", "SpellPower", "SpellInterrupts", - "SpellEquippedItems", "SpellAuraOptions", "SpellClassOptions", "SpellShapeshift", - "SpellXDescriptionVariables", "SpellDescriptionVariables", "SpellTargetRestrictions", - "SpellRange", "SpellRadius", "SpellProcsPerMinute", "SpellProcsPerMinuteMod", - "GlyphProperties", "SkillLineAbility", "Talent", "Faction", "Map", - "JournalEncounter", "JournalEncounterItem", "JournalInstance", "AreaTable", -} - -// SchemaDDL returns the whitespace-normalized sqlite_master entries, sorted, -// excluding objects related to item_enchantment_template (created later by -// gen_db's overrides, not by the extractor). -func SchemaDDL(db *sql.DB) ([]string, error) { - rows, err := db.Query(`SELECT type, name, tbl_name, COALESCE(sql,'') FROM sqlite_master - WHERE name != 'item_enchantment_template' AND tbl_name != 'item_enchantment_template' - ORDER BY type, name`) - if err != nil { - return nil, err - } - defer rows.Close() - var out []string - for rows.Next() { - var typ, name, tbl, ddl string - if err := rows.Scan(&typ, &name, &tbl, &ddl); err != nil { - return nil, err - } - out = append(out, fmt.Sprintf("%s|%s|%s|%s", typ, name, tbl, strings.Join(strings.Fields(ddl), " "))) - } - return out, rows.Err() -} - -// TableNames lists extractor-created tables in sqlite_master order. -func TableNames(db *sql.DB) ([]string, error) { - rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' - AND name != 'item_enchantment_template' ORDER BY name`) - if err != nil { - return nil, err - } - defer rows.Close() - var out []string - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - return nil, err - } - out = append(out, name) - } - return out, rows.Err() -} - -func pkColumn(db *sql.DB, table string) (string, error) { - var pk string - err := db.QueryRow(fmt.Sprintf("SELECT name FROM pragma_table_info('%s') WHERE pk=1", table)).Scan(&pk) - return pk, err -} - -// DumpRows returns one canonical line per row (SELECT * ORDER BY pk), using -// the driver's text rendering so both databases go through identical -// formatting. -func DumpRows(db *sql.DB, table string) ([]string, error) { - pk, err := pkColumn(db, table) - if err != nil { - return nil, fmt.Errorf("%s: no pk: %w", table, err) - } - rows, err := db.Query(fmt.Sprintf("SELECT * FROM [%s] ORDER BY [%s]", table, pk)) - if err != nil { - return nil, err - } - defer rows.Close() - cols, err := rows.Columns() - if err != nil { - return nil, err - } - var out []string - vals := make([]any, len(cols)) - ptrs := make([]any, len(cols)) - for i := range vals { - ptrs[i] = &vals[i] - } - var sb strings.Builder - for rows.Next() { - if err := rows.Scan(ptrs...); err != nil { - return nil, err - } - sb.Reset() - for i, v := range vals { - if i > 0 { - sb.WriteByte('|') - } - switch t := v.(type) { - case nil: - sb.WriteString("") - case []byte: - sb.Write(t) - case string: - sb.WriteString(t) - case int64: - fmt.Fprintf(&sb, "%d", t) - case float64: - // %v matches across both DBs; exactness comes from comparing - // the same driver rendering on both sides. - fmt.Fprintf(&sb, "%v", t) - default: - fmt.Fprintf(&sb, "%v", t) - } - } - out = append(out, sb.String()) - } - return out, rows.Err() -} - -// DiffLines reports lines present on only one side (unified count, not -// positions) — enough to gate parity and cheap on 100k-row tables. -func DiffLines(ref, got []string) (refOnly, gotOnly []string) { - counts := make(map[string]int, len(ref)) - for _, l := range ref { - counts[l]++ - } - for _, l := range got { - if counts[l] > 0 { - counts[l]-- - } else { - gotOnly = append(gotOnly, l) - } - } - for l, n := range counts { - for i := 0; i < n; i++ { - refOnly = append(refOnly, l) - } - } - return refOnly, gotOnly -} - -// FloatDiverges reports whether a float32 value falls where the reference -// database's shortest-round-trip text rendering and Go's diverge: the -// reference switches to scientific notation for |v| < 1e-4 or >= 1e15, Go -// only below 1e-6 or at >= 1e21. Zero is fine. -func FloatDiverges(v float32) bool { - a := math.Abs(float64(v)) - if a == 0 { - return false - } - return (a >= 1e-6 && a < 1e-4) || (a >= 1e15 && a < 1e21) -} diff --git a/tools/db2tool/main.go b/tools/db2tool/main.go index 19946bd975..81a814aeab 100644 --- a/tools/db2tool/main.go +++ b/tools/db2tool/main.go @@ -17,6 +17,8 @@ import ( "fmt" "os" "path/filepath" + "strconv" + "strings" "github.com/wowsims/mop/tools/db2tool/config" "github.com/wowsims/mop/tools/db2tool/dbd" @@ -78,11 +80,18 @@ func parseArgs(args []string) (options, error) { case "--build": var v string if v, err = next(); err == nil { - b, perr := dbd.ParseBuild("0.0.0." + v) + // Accept either a bare build number (68571) or a full version + // string (5.5.4.68571), of which only the trailing component + // identifies the build. + n := v + if dot := strings.LastIndexByte(n, '.'); dot >= 0 { + n = n[dot+1:] + } + b, perr := strconv.ParseUint(n, 10, 32) if perr != nil { - return opts, fmt.Errorf("invalid --build %q: %w", v, perr) + return opts, fmt.Errorf("invalid --build %q: want a build number like 68571 or a version like 5.5.4.68571", v) } - opts.buildNumber = b.Build + opts.buildNumber = uint32(b) } default: return opts, fmt.Errorf("unknown argument %q", args[i]) @@ -273,6 +282,11 @@ func run(args []string) error { } } hotfixReader = readers[buildNumber] + if hotfixReader == nil && len(opts.dbCaches) > 0 { + // Pinned caches that hold no records for the extracted build would + // otherwise silently produce a hotfix-free run. + fmt.Fprintf(os.Stderr, "db2tool: warning: none of the given --dbcache files hold hotfixes for build %d; continuing without the overlay\n", buildNumber) + } } for _, t := range tables { diff --git a/tools/db2tool/sqlite/insert.go b/tools/db2tool/sqlite/insert.go index e8e900e31e..2b8fd8fa8f 100644 --- a/tools/db2tool/sqlite/insert.go +++ b/tools/db2tool/sqlite/insert.go @@ -1,4 +1,5 @@ // Row insertion for the extracted tables. + package sqlite import ( diff --git a/tools/db2tool/sqlite/schema.go b/tools/db2tool/sqlite/schema.go index e03af794d7..ba6719b8a1 100644 --- a/tools/db2tool/sqlite/schema.go +++ b/tools/db2tool/sqlite/schema.go @@ -1,4 +1,6 @@ -// SQLite schema creation for the extracted tables. +// Package sqlite writes the extracted tables to wowsims.db: the schema (one +// table per .dbd definition, arrays as JSON text plus generated per-element +// columns) and the row inserts. This file is the schema half. package sqlite import ( diff --git a/tools/db2tool/sqlite/sqlite_test.go b/tools/db2tool/sqlite/sqlite_test.go index 09cecd90b4..1833dbacfa 100644 --- a/tools/db2tool/sqlite/sqlite_test.go +++ b/tools/db2tool/sqlite/sqlite_test.go @@ -51,17 +51,18 @@ func TestModerncMarshalingContract(t *testing.T) { } decoded := &wdc.Decoded{ - ColumnNames: []string{"ID", "Name", "Rate", "Stats", "Scales", "ParentID"}, Rows: []wdc.Row{ {ID: 1, Values: []any{int64(1), "first", float32(0.581), []int64{1, -2, 3}, []float32{0.1, 0}, int64(0)}}, {ID: 2, Values: []any{int64(2), "", float32(0), []int64{0, 0, 0}, []float32{0, 0}, int64(7)}}, + // Never upserted below, so it keeps its all-zero arrays. + {ID: 3, Values: []any{int64(3), "", float32(0), []int64{0, 0, 0}, []float32{0, 0}, int64(0)}}, }, } if err := InsertRows(db, td, decoded); err != nil { t.Fatal(err) } // Upsert (same PK) must update, not duplicate. - if err := InsertRows(db, td, &wdc.Decoded{ColumnNames: decoded.ColumnNames, Rows: []wdc.Row{ + if err := InsertRows(db, td, &wdc.Decoded{Rows: []wdc.Row{ {ID: 2, Values: []any{int64(2), "second", float32(1.5), []int64{9, 9, 9}, []float32{2.5, 0}, int64(7)}}, }}); err != nil { t.Fatal(err) @@ -71,8 +72,8 @@ func TestModerncMarshalingContract(t *testing.T) { if err := db.QueryRow("SELECT count(*) FROM Smoke").Scan(&n); err != nil { t.Fatal(err) } - if n != 2 { - t.Fatalf("expected 2 rows after upsert, got %d", n) + if n != 3 { + t.Fatalf("expected 3 rows after upsert, got %d", n) } // float32 scalar must store the double-widened value. @@ -104,16 +105,27 @@ func TestModerncMarshalingContract(t *testing.T) { t.Errorf("Scales_0 = %v, want 0.1", scales0) } - // All-zero arrays serialize as [0,...], never NULL/[]/"". + // The upsert must have replaced row 2's arrays wholesale. + var upsertedStats, upsertedScales string + if err := db.QueryRow("SELECT Stats, Scales FROM Smoke WHERE ID=2").Scan(&upsertedStats, &upsertedScales); err != nil { + t.Fatal(err) + } + if upsertedStats != "[9,9,9]" || upsertedScales != "[2.5,0]" { + t.Errorf("upserted arrays = %q / %q, want [9,9,9] / [2.5,0]", upsertedStats, upsertedScales) + } + + // All-zero arrays serialize as [0,...], never NULL/[]/"" — checked on the + // row that was never upserted. var zeroStats, zeroScales string - if err := db.QueryRow("SELECT Stats, Scales FROM Smoke WHERE ID=2").Scan(&zeroStats, &zeroScales); err != nil { + if err := db.QueryRow("SELECT Stats, Scales FROM Smoke WHERE ID=3").Scan(&zeroStats, &zeroScales); err != nil { t.Fatal(err) } - if zeroStats != "[9,9,9]" || zeroScales != "[2.5,0]" { - t.Errorf("upserted arrays = %q / %q, want [9,9,9] / [2.5,0]", zeroStats, zeroScales) + if zeroStats != "[0,0,0]" || zeroScales != "[0,0]" { + t.Errorf("all-zero arrays = %q / %q, want [0,0,0] / [0,0]", zeroStats, zeroScales) } - // Relation value 0 stays 0 — never converted to NULL (§5.4). + // Relation value 0 stays 0 — never converted to NULL. The C# original's + // relation-0-to-NULL branch was dead code (a boxed reference compare). var parent sql.NullInt64 if err := db.QueryRow("SELECT ParentID FROM Smoke WHERE ID=1").Scan(&parent); err != nil { t.Fatal(err) diff --git a/tools/db2tool/tact/blte.go b/tools/db2tool/tact/blte.go index d54c14e76c..3d73eaba14 100644 --- a/tools/db2tool/tact/blte.go +++ b/tools/db2tool/tact/blte.go @@ -5,6 +5,7 @@ // Keyless: no TACT keys are loaded, so 'E' (encrypted) chunks are left // zero-filled in the output — exactly what the WDC layer's encrypted-section // skip expects. 'F' never occurs. + package tact import ( @@ -47,7 +48,7 @@ func blteDecode(data []byte, totalDecompSize uint64) ([]byte, error) { if totalDecompSize == 0 { o := infoStart + 4 - for i := 0; i < chunkCount; i++ { + for range chunkCount { totalDecompSize += uint64(be32(data[o:])) o += blockInfoSize } @@ -58,7 +59,7 @@ func blteDecode(data []byte, totalDecompSize uint64) ([]byte, error) { compOffset := headerSize decompOffset := 0 - for chunk := 0; chunk < chunkCount; chunk++ { + for chunk := range chunkCount { compSize := int(be32(data[infoOffset:])) decompSize := int(be32(data[infoOffset+4:])) if compOffset+compSize > len(data) || decompOffset+decompSize > len(out) { diff --git a/tools/db2tool/tact/build.go b/tools/db2tool/tact/build.go index f8605194e3..e9dd429707 100644 --- a/tools/db2tool/tact/build.go +++ b/tools/db2tool/tact/build.go @@ -4,6 +4,10 @@ // → local .idx → data.NNN → BLTE. No CDN, no group/file indices (upstream // consults them but the local .idx always wins for resident files). // Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. + +// Package tact reads files out of a local World of Warcraft CASC install: +// .build.info picks the build, then root → encoding → .idx → data.NNN → BLTE +// resolves a file data id to its bytes. There is no CDN fallback. package tact import ( @@ -15,8 +19,6 @@ import ( type Build struct { Entry AvailableBuild BuildNumber uint32 - BuildConfig map[string][]string - CDNConfig map[string][]string store *cascStore encoding *encodingTable @@ -38,14 +40,13 @@ func Open(baseDir, product string) (*Build, error) { return nil, err } + // Only the build config is needed: it names the encoding and root files. + // The CDN config describes remote archives this local-only reader never + // touches. buildConfig, err := LoadConfig(baseDir, entry.BuildConfig) if err != nil { return nil, fmt.Errorf("loading build config: %w", err) } - cdnConfig, err := LoadConfig(baseDir, entry.CDNConfig) - if err != nil { - return nil, fmt.Errorf("loading cdn config: %w", err) - } store, err := openCascStore(baseDir) if err != nil { @@ -55,8 +56,6 @@ func Open(baseDir, product string) (*Build, error) { b := &Build{ Entry: entry, BuildNumber: buildNumber, - BuildConfig: buildConfig, - CDNConfig: cdnConfig, store: store, } diff --git a/tools/db2tool/tact/buildinfo.go b/tools/db2tool/tact/buildinfo.go index 100f6579a3..11bdebeeb1 100644 --- a/tools/db2tool/tact/buildinfo.go +++ b/tools/db2tool/tact/buildinfo.go @@ -1,6 +1,7 @@ // Go translation of TACTSharp's BuildInfo (https://github.com/wowdev/TACTSharp, // v0.0.13-alpha, commit d0ab516eb98b5db35682467b6e4977d88955046d). // Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. + package tact import ( @@ -10,10 +11,10 @@ import ( "strings" ) +// AvailableBuild is one .build.info product entry, reduced to the fields this +// local-only reader consumes. type AvailableBuild struct { BuildConfig string - CDNConfig string - CDNPath string Version string Product string } @@ -27,7 +28,7 @@ func ParseBuildInfo(path string) ([]AvailableBuild, error) { } var entries []AvailableBuild headerMap := map[string]int{} - for _, line := range strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n") { + for line := range strings.SplitSeq(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n") { if line == "" { continue } @@ -47,8 +48,6 @@ func ParseBuildInfo(path string) ([]AvailableBuild, error) { } entries = append(entries, AvailableBuild{ BuildConfig: col("Build Key"), - CDNConfig: col("CDN Key"), - CDNPath: col("CDN Path"), Version: col("Version"), Product: col("Product"), }) diff --git a/tools/db2tool/tact/cascidx.go b/tools/db2tool/tact/cascidx.go index 2bccd99860..78cc0f801d 100644 --- a/tools/db2tool/tact/cascidx.go +++ b/tools/db2tool/tact/cascidx.go @@ -2,6 +2,7 @@ // from CDN.TryGetLocalFile (https://github.com/wowdev/TACTSharp, // v0.0.13-alpha, commit d0ab516eb98b5db35682467b6e4977d88955046d). // Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. + package tact import ( diff --git a/tools/db2tool/tact/config.go b/tools/db2tool/tact/config.go index f829b6957d..42d91d34ed 100644 --- a/tools/db2tool/tact/config.go +++ b/tools/db2tool/tact/config.go @@ -1,6 +1,7 @@ // Go translation of TACTSharp's Config (https://github.com/wowdev/TACTSharp, // v0.0.13-alpha, commit d0ab516eb98b5db35682467b6e4977d88955046d). // Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. + package tact import ( @@ -27,7 +28,7 @@ func LoadConfig(baseDir, hash string) (map[string][]string, error) { return nil, fmt.Errorf("%s: config file is unreadable", path) } values := map[string][]string{} - for _, line := range strings.Split(string(raw), "\n") { + for line := range strings.SplitSeq(string(raw), "\n") { splitLine := strings.SplitN(line, "=", 2) if len(splitLine) > 1 { values[strings.TrimSpace(splitLine[0])] = strings.Split(strings.TrimSpace(splitLine[1]), " ") diff --git a/tools/db2tool/tact/encoding.go b/tools/db2tool/tact/encoding.go index f7b6bfd503..08794c329c 100644 --- a/tools/db2tool/tact/encoding.go +++ b/tools/db2tool/tact/encoding.go @@ -1,6 +1,7 @@ // Go translation of TACTSharp's EncodingInstance (https://github.com/wowdev/TACTSharp, // v0.0.13-alpha, commit d0ab516eb98b5db35682467b6e4977d88955046d). // Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. + package tact import ( diff --git a/tools/db2tool/tact/fdid.go b/tools/db2tool/tact/fdid.go index 3067e79343..950bc2e2cf 100644 --- a/tools/db2tool/tact/fdid.go +++ b/tools/db2tool/tact/fdid.go @@ -1,6 +1,7 @@ // FDID resolution: a static path→FDID map for the configured tables/gametables // (primary; FDIDs are stable per path), with the community listfile.csv as the // fallback for paths not in the map. Lookups use plain case-normalized paths. + package tact import ( diff --git a/tools/db2tool/tact/listfile.go b/tools/db2tool/tact/listfile.go index f49a83994d..dacca897f3 100644 --- a/tools/db2tool/tact/listfile.go +++ b/tools/db2tool/tact/listfile.go @@ -2,17 +2,32 @@ // (https://github.com/wowdev/TACTSharp, v0.0.13-alpha, commit // d0ab516eb98b5db35682467b6e4977d88955046d). // Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. + package tact import ( "fmt" "io" + "net" "net/http" "os" "time" ) -const DefaultListfileURL = "https://github.com/wowdev/wow-listfile/releases/latest/download/community-listfile.csv" +const defaultListfileURL = "https://github.com/wowdev/wow-listfile/releases/latest/download/community-listfile.csv" + +// headClient bounds the freshness probe; downloadClient bounds connect and +// response-header time but NOT the transfer, since the listfile is ~150 MB and +// a slow-but-progressing download must not be cut off. Without these a stalled +// connection would hang make db indefinitely. +var ( + headClient = &http.Client{Timeout: 30 * time.Second} + downloadClient = &http.Client{Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: 30 * time.Second}).DialContext, + TLSHandshakeTimeout: 30 * time.Second, + ResponseHeaderTimeout: 60 * time.Second, + }} +) // Listfile manages the community listfile.csv with download-if-stale // semantics: HEAD + Last-Modified vs local mtime; a failed freshness check @@ -30,11 +45,11 @@ type Listfile struct { func (l *Listfile) Refresh() error { url := l.URL if url == "" { - url = DefaultListfileURL + url = defaultListfileURL } info, statErr := os.Stat(l.Path) if statErr == nil { - resp, err := http.Head(url) + resp, err := headClient.Head(url) if err == nil { lastModified, perr := time.Parse(http.TimeFormat, resp.Header.Get("Last-Modified")) resp.Body.Close() @@ -57,7 +72,7 @@ func (l *Listfile) Refresh() error { } func (l *Listfile) download(url string) error { - resp, err := http.Get(url) + resp, err := downloadClient.Get(url) if err != nil { return err } diff --git a/tools/db2tool/tact/root.go b/tools/db2tool/tact/root.go index 78110b8d08..a041f93932 100644 --- a/tools/db2tool/tact/root.go +++ b/tools/db2tool/tact/root.go @@ -2,6 +2,7 @@ // v0.0.13-alpha, commit d0ab516eb98b5db35682467b6e4977d88955046d) — Normal // load mode, enUS locale, FDID→CKey only. // Copyright (c) 2024 Martin Benjamins. MIT License — see tools/db2tool/NOTICES.md. + package tact import ( @@ -90,7 +91,7 @@ func parseRoot(data []byte) (*rootTable, error) { if !skipChunk { fileDataIndex := uint32(0) - for i := 0; i < count; i++ { + for range count { fdidOffset := binary.LittleEndian.Uint32(data[offsetFdid:]) offsetFdid += sizeFdid fdid := fileDataIndex + fdidOffset diff --git a/tools/db2tool/wdc/bitreader.go b/tools/db2tool/wdc/bitreader.go index bf9df831a6..1551f4ff93 100644 --- a/tools/db2tool/wdc/bitreader.go +++ b/tools/db2tool/wdc/bitreader.go @@ -1,11 +1,11 @@ // Go translation of DBCD.IO's BitReader (https://github.com/wowdev/DBCD, // v2.1.2, commit 2180edb4d08b3822b3cfa964293ba8ccd4236ac0). // Copyright (c) 2020 wowdev. MIT License — see tools/db2tool/NOTICES.md. + package wdc import ( "encoding/binary" - "math" ) // bitReader reads unaligned little-endian bit windows: a raw 4/8-byte load @@ -75,13 +75,6 @@ func (r *bitReader) ReadCString() string { return string(bytes) } -func (r *bitReader) clone() *bitReader { - return &bitReader{data: r.data} -} - -// value32 is 4 raw bytes reinterpreted on demand. +// value32 is 4 raw bytes from the pallet/common blocks, reinterpreted by the +// caller per the DBD-declared field type. type value32 uint32 - -func (v value32) Float32() float32 { return math.Float32frombits(uint32(v)) } -func (v value32) Int32() int32 { return int32(v) } -func (v value32) Uint32() uint32 { return uint32(v) } diff --git a/tools/db2tool/wdc/bitreader_test.go b/tools/db2tool/wdc/bitreader_test.go new file mode 100644 index 0000000000..0d26f1dc24 --- /dev/null +++ b/tools/db2tool/wdc/bitreader_test.go @@ -0,0 +1,166 @@ +package wdc + +import ( + "math" + "testing" +) + +// bitAt returns bit k of the little-endian bit stream: byte i supplies bits +// [8i, 8i+8), least-significant first. This is the definition the WDC5 record +// layout uses and is deliberately independent of bitReader's shift arithmetic. +func bitAt(data []byte, k int) uint64 { + return uint64(data[k/8]>>(k%8)) & 1 +} + +// refRead is the reference extraction: numBits starting at bit position pos. +func refRead(data []byte, pos, numBits int) uint64 { + var v uint64 + for j := range numBits { + v |= bitAt(data, pos+j) << j + } + return v +} + +func testData() []byte { + // Fixed pseudo-random bytes; no Math/rand so failures are reproducible. + data := make([]byte, 32) + x := byte(0x9d) + for i := range data { + data[i] = x + x = x*31 + 17 + } + return padRecordData(data) +} + +// ReadUInt32's shift math requires 32-numBits-(pos&7) >= 0, i.e. numBits <= 25 +// for an arbitrary bit offset. The decoder only ever calls it with small widths +// (pallet indices of cm.B bits, and 8 for ReadCString), so that is the range +// worth pinning. +func TestReadUInt32AllOffsetsAndWidths(t *testing.T) { + data := testData() + for pos := range 64 { + for numBits := 1; numBits <= 25; numBits++ { + r := &bitReader{data: data, Position: pos} + got := uint64(r.ReadUInt32(numBits)) + want := refRead(data, pos, numBits) + if got != want { + t.Fatalf("ReadUInt32(pos=%d, bits=%d) = %#x, want %#x", pos, numBits, got, want) + } + if r.Position != pos+numBits { + t.Fatalf("ReadUInt32(pos=%d, bits=%d) left Position=%d, want %d", pos, numBits, r.Position, pos+numBits) + } + } + } +} + +// ReadUInt64 backs every field read (ReadValue64); its constraint is +// numBits <= 57 for an arbitrary bit offset. +func TestReadUInt64AllOffsetsAndWidths(t *testing.T) { + data := testData() + for pos := range 64 { + for numBits := 1; numBits <= 57; numBits++ { + r := &bitReader{data: data, Position: pos} + got := r.ReadValue64(numBits) + want := refRead(data, pos, numBits) + if got != want { + t.Fatalf("ReadValue64(pos=%d, bits=%d) = %#x, want %#x", pos, numBits, got, want) + } + } + } +} + +// Offset is a byte-granular base that must compose with the bit Position. +func TestReadHonoursByteOffset(t *testing.T) { + data := testData() + for _, offset := range []int{0, 1, 7, 16} { + for _, numBits := range []int{1, 8, 13, 32} { + r := &bitReader{data: data, Offset: offset, Position: 3} + got := r.ReadValue64(numBits) + want := refRead(data, offset*8+3, numBits) + if got != want { + t.Fatalf("Offset=%d bits=%d: got %#x, want %#x", offset, numBits, got, want) + } + } + } +} + +func TestReadValue64Signed(t *testing.T) { + cases := []struct { + bits int + raw uint64 + want int64 + }{ + {8, 0x7f, 127}, + {8, 0x80, -128}, + {8, 0xff, -1}, + {16, 0x7fff, 32767}, + {16, 0x8000, -32768}, + {4, 0x7, 7}, + {4, 0x8, -8}, + {32, 0xffffffff, -1}, + {32, 0x80000000, math.MinInt32}, + } + for _, c := range cases { + // Lay the raw value down at bit 0 of a fresh buffer. + data := make([]byte, 16) + for j := range c.bits { + if c.raw>>j&1 == 1 { + data[j/8] |= 1 << (j % 8) + } + } + r := newBitReader(padRecordData(data)) + if got := int64(r.ReadValue64Signed(c.bits)); got != c.want { + t.Errorf("ReadValue64Signed(%d bits, raw %#x) = %d, want %d", c.bits, c.raw, got, c.want) + } + } +} + +func TestReadCString(t *testing.T) { + data := append([]byte("abc\x00de\x00"), 0) + r := newBitReader(padRecordData(data)) + if got := r.ReadCString(); got != "abc" { + t.Errorf("first ReadCString = %q, want \"abc\"", got) + } + if got := r.ReadCString(); got != "de" { + t.Errorf("second ReadCString = %q, want \"de\"", got) + } + // An immediately-terminated string is empty, not a read past the end. + if got := r.ReadCString(); got != "" { + t.Errorf("third ReadCString = %q, want \"\"", got) + } +} + +// padRecordData must COPY: the records block aliases the mapped file buffer, so +// appending in place would overwrite the bytes that follow it. +func TestPadRecordDataCopies(t *testing.T) { + file := []byte{1, 2, 3, 4, 5, 6, 7, 8} + records := file[:4] + padded := padRecordData(records) + + if len(padded) != len(records)+8 { + t.Fatalf("padded length = %d, want %d", len(padded), len(records)+8) + } + for i, b := range padded[len(records):] { + if b != 0 { + t.Errorf("pad byte %d = %d, want 0", i, b) + } + } + padded[5] = 0xff + if file[5] != 6 { + t.Errorf("padRecordData wrote through to the backing buffer: file[5] = %d, want 6", file[5]) + } +} + +// A read at the very last meaningful byte still loads 8 bytes, which is exactly +// what the padding exists for. +func TestReadAtTailIsInBounds(t *testing.T) { + data := padRecordData([]byte{0xaa}) + r := newBitReader(data) + if got := r.ReadValue64(8); got != 0xaa { + t.Errorf("tail ReadValue64(8) = %#x, want 0xaa", got) + } + r2 := &bitReader{data: data, Offset: 1} + if got := r2.ReadValue64(32); got != 0 { + t.Errorf("read into padding = %#x, want 0", got) + } +} diff --git a/tools/db2tool/wdc/hotfix.go b/tools/db2tool/wdc/hotfix.go index cc7e88bba0..76487d7476 100644 --- a/tools/db2tool/wdc/hotfix.go +++ b/tools/db2tool/wdc/hotfix.go @@ -4,6 +4,7 @@ // (https://github.com/Marlamin/wow.tools.local). // Copyright (c) 2020 wowdev; Copyright (c) 2022 Martin Benjamins. // MIT License — see tools/db2tool/NOTICES.md. + package wdc import ( @@ -13,6 +14,7 @@ import ( "math" "os" "path/filepath" + "slices" "sort" "strings" @@ -286,7 +288,7 @@ func (h *HotfixReader) ApplyHotfixes(t *Table, def dbd.DBDefinition, version dbd for id := range byID { ids = append(ids, id) } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) rows := make([]Row, len(ids)) for i, id := range ids { diff --git a/tools/db2tool/wdc/hotfix_test.go b/tools/db2tool/wdc/hotfix_test.go new file mode 100644 index 0000000000..43385a5f0b --- /dev/null +++ b/tools/db2tool/wdc/hotfix_test.go @@ -0,0 +1,170 @@ +package wdc + +import ( + "encoding/binary" + "testing" +) + +// The two table hashes ApplyHotfixes special-cases are known constants, so they +// pin the SStrHash port without needing any client data. A DBCache record is +// matched to a table by comparing this hash against the WDC5 header's +// TableHash, so a wrong hash silently drops every hotfix for a table. +func TestSStrHashKnownTableHashes(t *testing.T) { + cases := map[string]uint32{ + "TACTKEY": 0xDF2F53CF, + "BROADCASTTEXT": 0x021826BB, + } + for name, want := range cases { + if got := SStrHash(name); got != want { + t.Errorf("SStrHash(%q) = 0x%08X, want 0x%08X", name, got, want) + } + } + // Callers must uppercase; the hash is case-sensitive and the lowercase form + // is a different value. + if SStrHash("tactkey") == SStrHash("TACTKEY") { + t.Error("SStrHash is unexpectedly case-insensitive") + } + if SStrHash("") != 0x7fed7fed { + t.Errorf("SStrHash(\"\") = 0x%08X, want the 0x7fed7fed seed", SStrHash("")) + } +} + +// xfthEntry builds one DBCache record: per-entry magic, the 28-byte header, then +// the payload. +func xfthEntry(pushID int32, tableHash uint32, recordID int32, status byte, data []byte) []byte { + b := make([]byte, 0, 32+len(data)) + b = append(b, []byte(hotfixMagic)...) + var hdr [28]byte + binary.LittleEndian.PutUint32(hdr[0:], 1) // RegionID + binary.LittleEndian.PutUint32(hdr[4:], uint32(pushID)) + binary.LittleEndian.PutUint32(hdr[8:], 42) // UniqueID + binary.LittleEndian.PutUint32(hdr[12:], tableHash) + binary.LittleEndian.PutUint32(hdr[16:], uint32(recordID)) + binary.LittleEndian.PutUint32(hdr[20:], uint32(len(data))) + hdr[24] = status + b = append(b, hdr[:]...) + return append(b, data...) +} + +func xfthFile(version, build int32, entries ...[]byte) []byte { + buf := make([]byte, 44) + copy(buf, hotfixMagic) + binary.LittleEndian.PutUint32(buf[4:], uint32(version)) + binary.LittleEndian.PutUint32(buf[8:], uint32(build)) + // buf[12:44] is the v>=5 32-byte hash, skipped by the parser. + for _, e := range entries { + buf = append(buf, e...) + } + return buf +} + +func TestParseHotfixV9(t *testing.T) { + const build = 68571 + raw := xfthFile(9, build, + xfthEntry(100, 0xAABBCCDD, 7, 1, []byte{1, 2, 3, 4}), + xfthEntry(101, 0xAABBCCDD, 8, 0, nil), // delete: status 0, no payload + ) + + h, err := parseHotfix(raw) + if err != nil { + t.Fatal(err) + } + if h.Version != 9 || h.BuildID != build { + t.Fatalf("header = version %d build %d, want 9 / %d", h.Version, h.BuildID, build) + } + if len(h.records) != 2 { + t.Fatalf("parsed %d records, want 2", len(h.records)) + } + + first := h.records[0] + if first.PushID != 100 || first.TableHash != 0xAABBCCDD || first.RecordID != 7 { + t.Errorf("first record = %+v", first) + } + if !first.IsValid { + t.Error("status byte 1 must parse as valid") + } + if first.DataSize != 4 || string(first.Data) != "\x01\x02\x03\x04" { + t.Errorf("first payload = %v (size %d), want 4 bytes 1..4", first.Data, first.DataSize) + } + if h.records[1].IsValid { + t.Error("status byte 0 must parse as invalid") + } + if h.records[1].DataSize != 0 { + t.Errorf("second DataSize = %d, want 0", h.records[1].DataSize) + } +} + +// The payload must be capped so a later append cannot reach into the following +// record's bytes. +func TestParseHotfixPayloadIsCapped(t *testing.T) { + raw := xfthFile(9, 1, + xfthEntry(1, 0x1, 1, 1, []byte{9, 9}), + xfthEntry(2, 0x1, 2, 1, []byte{7, 7}), + ) + h, err := parseHotfix(raw) + if err != nil { + t.Fatal(err) + } + _ = append(h.records[0].Data, 0xff) + if h.records[1].Data[0] != 7 { + t.Errorf("appending to record 0's payload corrupted record 1: %v", h.records[1].Data) + } +} + +func TestParseHotfixRejectsBadInput(t *testing.T) { + badMagic := xfthFile(9, 1) + copy(badMagic, "ZZZZ") + + tests := map[string][]byte{ + "short header": []byte("XFT"), + "bad magic": badMagic, + "truncated ext": append([]byte(hotfixMagic), 0x09, 0, 0, 0, 0x01, 0, 0, 0), + "bad entry magic": append(xfthFile(9, 1), []byte("NOPE????")...), + "truncated data": append(xfthFile(9, 1), xfthEntry(1, 0x1, 1, 1, []byte{1, 2, 3, 4})[:34]...), + "unsupported ver8": xfthFile(8, 1), + } + + for name, raw := range tests { + if _, err := parseHotfix(raw); err == nil { + t.Errorf("%s: expected an error, got nil", name) + } + } +} + +// Records with an unsupported DataSize must not be silently accepted: a +// negative size would otherwise index backwards. +func TestParseHotfixRejectsNegativeDataSize(t *testing.T) { + e := xfthEntry(1, 0x1, 1, 1, nil) + binary.LittleEndian.PutUint32(e[4+20:], 0xFFFFFFFF) // DataSize = -1 + if _, err := parseHotfix(xfthFile(9, 1, e)); err == nil { + t.Error("expected an error for a negative DataSize") + } +} + +func TestCombineDedupsOnFullRecordIdentity(t *testing.T) { + rec := func(push int32, data []byte) []byte { return xfthEntry(push, 0x1, 5, 1, data) } + + base, err := parseHotfix(xfthFile(9, 1, rec(1, []byte{1}))) + if err != nil { + t.Fatal(err) + } + // Identical record: dropped. + same, _ := parseHotfix(xfthFile(9, 1, rec(1, []byte{1}))) + base.Combine(same) + if len(base.records) != 1 { + t.Fatalf("identical record was not deduped: %d records", len(base.records)) + } + // Same 5-tuple but different payload bytes: kept, because identity includes + // the data. + other, _ := parseHotfix(xfthFile(9, 1, rec(1, []byte{2}))) + base.Combine(other) + if len(base.records) != 2 { + t.Fatalf("record differing only in payload was dropped: %d records", len(base.records)) + } + // A different build is ignored entirely. + otherBuild, _ := parseHotfix(xfthFile(9, 2, rec(9, []byte{3}))) + base.Combine(otherBuild) + if len(base.records) != 2 { + t.Fatalf("record from another build was merged in: %d records", len(base.records)) + } +} diff --git a/tools/db2tool/wdc/row.go b/tools/db2tool/wdc/row.go index 3120688eaa..13d310f163 100644 --- a/tools/db2tool/wdc/row.go +++ b/tools/db2tool/wdc/row.go @@ -2,12 +2,13 @@ // copy-row semantics (https://github.com/wowdev/DBCD, v2.1.2, commit // 2180edb4d08b3822b3cfa964293ba8ccd4236ac0). // Copyright (c) 2020 wowdev. MIT License — see tools/db2tool/NOTICES.md. + package wdc import ( "fmt" "math" - "sort" + "slices" "github.com/wowsims/mop/tools/db2tool/dbd" ) @@ -40,7 +41,8 @@ type fieldPlan struct { hfSigned bool } -// Row is one decoded record; Values align 1:1 with Decoded.ColumnNames. +// Row is one decoded record; Values align 1:1 with the version block's +// Definitions, which is also the column order the sqlite writer binds. // Value dynamic types are limited to int64/uint64 scalars, float32, string, // []int64, []uint64, []float32 and []string — never []byte, so encoding/json // writes every array element-by-element as numbers (no base64), the @@ -51,8 +53,7 @@ type Row struct { } type Decoded struct { - ColumnNames []string - Rows []Row // ascending ID + Rows []Row // ascending ID } func buildFieldPlans(def dbd.DBDefinition, version dbd.VersionDefinitions, buildNumber uint32) ([]fieldPlan, error) { @@ -116,11 +117,6 @@ func (t *Table) DecodeRows(def dbd.DBDefinition, version dbd.VersionDefinitions, return nil, err } - columnNames := make([]string, len(plans)) - for i, p := range plans { - columnNames[i] = p.name - } - byID := make(map[int32][]any, len(t.rows)) hadInlineID := false @@ -150,6 +146,17 @@ func (t *Table) DecodeRows(def dbd.DBDefinition, version dbd.VersionDefinitions, if idFieldIndex >= len(plans) { return nil, fmt.Errorf("IdFieldIndex %d out of range for %d definitions", idFieldIndex, len(plans)) } + // Cloning the source row's decoded values is only equivalent to + // upstream's re-decode-with-the-destination-id while no column is + // COMMON-compressed: a common value is looked up BY ROW ID + // (getFieldRaw), so a copy row would wrongly inherit the source id's + // value. No table has both today; fail loud if a future build changes + // that rather than emit silently wrong rows. + for i := range t.ColumnMeta { + if t.ColumnMeta[i].CompressionType == compressionCommon { + return nil, fmt.Errorf("table has both a copy table and a COMMON-compressed column (field %d) — copy rows would resolve common data by the source id; decode copy rows per destination id instead", i) + } + } for _, ce := range t.copyData { src, ok := byID[ce.Src] if !ok { @@ -169,9 +176,9 @@ func (t *Table) DecodeRows(def dbd.DBDefinition, version dbd.VersionDefinitions, for id := range byID { ids = append(ids, id) } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) - decoded := &Decoded{ColumnNames: columnNames, Rows: make([]Row, len(ids))} + decoded := &Decoded{Rows: make([]Row, len(ids))} for i, id := range ids { decoded.Rows[i] = Row{ID: id, Values: byID[id]} } @@ -238,10 +245,7 @@ func (t *Table) readScalarField(id int32, r *bitReader, fieldIndex int, p fieldP if err != nil { return nil, err } - index := recordOffset + bytePos + int(int32(uint32(raw))) - if index < 0 { - index = 0 - } + index := max(recordOffset+bytePos+int(int32(uint32(raw))), 0) s, ok := t.StringTable[int64(index)] if !ok { return nil, fmt.Errorf("string table miss at offset %d", index) @@ -310,10 +314,7 @@ func (t *Table) readArrayField(r *bitReader, fieldIndex int, p fieldPlan, row ra for i := range out { bytePos := r.Position >> 3 raw := r.ReadValue64(bitSize) - index := bytePos + recordOffset + int(int32(uint32(raw))) - if index < 0 { - index = 0 - } + index := max(bytePos+recordOffset+int(int32(uint32(raw))), 0) s, ok := t.StringTable[int64(index)] if !ok { return nil, fmt.Errorf("string table miss at offset %d", index) diff --git a/tools/db2tool/wdc/wdc5.go b/tools/db2tool/wdc/wdc5.go index 5fb815f30d..2ae2010f7b 100644 --- a/tools/db2tool/wdc/wdc5.go +++ b/tools/db2tool/wdc/wdc5.go @@ -2,6 +2,9 @@ // v2.1.2, commit 2180edb4d08b3822b3cfa964293ba8ccd4236ac0), including its // encrypted-section skip path (no TACT keys). // Copyright (c) 2020 wowdev. MIT License — see tools/db2tool/NOTICES.md. + +// Package wdc decodes WDC5 .db2 client tables into rows shaped by a .dbd +// definition, and overlays the client's XFTH DBCache hotfix records onto them. package wdc import ( @@ -80,19 +83,16 @@ type copyEntry struct { Src int32 } -// Table is the parsed (but not field-decoded) WDC5 file. +// Table is the parsed (but not field-decoded) WDC5 file. Header fields the +// decoder does not consume (schema version/string, layout hash, max index, +// locale) are read past rather than kept. type Table struct { - SchemaVersion uint32 - SchemaString string RecordsCount int32 FieldsCount int32 RecordSize int32 StringTableSize int32 TableHash uint32 - LayoutHash uint32 MinIndex int32 - MaxIndex int32 - Locale int32 Flags db2Flags IdFieldIndex uint16 @@ -107,8 +107,9 @@ type Table struct { rows []rawRow copyData []copyEntry // file order; dest==src entries already dropped - // SkippedSections counts encrypted sections whose data was zero-filled - // and therefore skipped (diagnostics for the golden harness). + // SkippedSections counts encrypted sections whose data was zero-filled and + // therefore skipped, which is why the header record count exceeds the number + // of emitted rows. SkippedSections int } @@ -184,15 +185,16 @@ func read(buf []byte) (*Table, error) { t := &Table{} var err error - if t.SchemaVersion, err = c.u32(); err != nil { + // schema version (u32) + schema string (128 bytes): unused. + if _, err = c.u32(); err != nil { return nil, err } - schemaBytes, err := c.need(128) - if err != nil { + if _, err = c.need(128); err != nil { return nil, err } - t.SchemaString = strings.TrimRight(string(schemaBytes), "\x00") + // record_count, field_count, record_size, string_table_size, table_hash, + // layout_hash, min_id, max_id, locale ints := make([]int32, 9) for i := range ints { if ints[i], err = c.i32(); err != nil { @@ -200,8 +202,8 @@ func read(buf []byte) (*Table, error) { } } t.RecordsCount, t.FieldsCount, t.RecordSize, t.StringTableSize = ints[0], ints[1], ints[2], ints[3] - t.TableHash, t.LayoutHash = uint32(ints[4]), uint32(ints[5]) - t.MinIndex, t.MaxIndex, t.Locale = ints[6], ints[7], ints[8] + t.TableHash = uint32(ints[4]) + t.MinIndex = ints[6] flags, err := c.u16() if err != nil { @@ -298,7 +300,7 @@ func read(buf []byte) (*Table, error) { if ct == compressionPallet || ct == compressionPalletArray { n := int(t.ColumnMeta[i].AdditionalDataSize / 4) t.PalletData[i] = make([]value32, n) - for j := 0; j < n; j++ { + for j := range n { v, err := c.u32() if err != nil { return nil, err @@ -315,7 +317,7 @@ func read(buf []byte) (*Table, error) { n := int(t.ColumnMeta[i].AdditionalDataSize / 8) m := make(map[int32]value32, n) t.CommonData[i] = m - for j := 0; j < n; j++ { + for range n { k, err := c.i32() if err != nil { return nil, err @@ -331,7 +333,7 @@ func read(buf []byte) (*Table, error) { // encrypted ID lists (read sequentially; content unused — this tool // never consults them) - for i := 0; i < sectionsCount; i++ { + for i := range sectionsCount { if t.Sections[i].TactKeyLookup == 0 { continue } @@ -468,7 +470,7 @@ func read(buf []byte) (*Table, error) { if _, err := c.need(8); err != nil { // minId, maxId return nil, err } - for i := int32(0); i < numRecords; i++ { + for range numRecords { id, err := c.i32() if err != nil { return nil, err @@ -548,7 +550,7 @@ func readStringTable(dst map[int64]string, data []byte, baseOffset int64) { return } curOfs := 0 - for _, str := range strings.Split(string(data), "\x00") { + for str := range strings.SplitSeq(string(data), "\x00") { if curOfs == len(data) { break } diff --git a/tools/db2tool/wdc/wdc5_test.go b/tools/db2tool/wdc/wdc5_test.go deleted file mode 100644 index 44be5df6c8..0000000000 --- a/tools/db2tool/wdc/wdc5_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package wdc - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/wowsims/mop/tools/db2tool/dbd" -) - -// Decoder test fixtures, frozen from the gitignored build-68571 .db2 -// snapshot. Tests skip when the snapshot is absent. -const db2Dir = "../refs/dbfilesclient" -const dbdDir = "../refs/DBDCache" -const snapshotBuild = 68571 - -func db2Files(t *testing.T) []string { - t.Helper() - entries, err := os.ReadDir(db2Dir) - if os.IsNotExist(err) { - t.Skipf("%s not present (gitignored snapshot); skipping", db2Dir) - } - if err != nil { - t.Fatal(err) - } - var files []string - for _, e := range entries { - if strings.HasSuffix(e.Name(), ".db2") { - files = append(files, e.Name()) - } - } - if len(files) != 72 { - t.Fatalf("expected 72 .db2 files, got %d", len(files)) - } - return files -} - -func TestParseAllHeaders(t *testing.T) { - sectionCounts := map[int]bool{} - for _, name := range db2Files(t) { - table, err := ReadFile(filepath.Join(db2Dir, name)) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - sectionCounts[len(table.Sections)] = true - - base := strings.TrimSuffix(name, ".db2") - switch base { - case "ItemBonus": - if len(table.Sections) != 0 && table.RecordsCount != 0 { - t.Errorf("ItemBonus: expected empty table, got %d sections / %d records", len(table.Sections), table.RecordsCount) - } - case "Spell", "ItemSparse": - if table.Flags != 0x5 { - t.Errorf("%s: Flags = 0x%x, want 0x5 (Sparse|Index)", base, table.Flags) - } - } - if table.Flags&flagSecondaryKey != 0 { - t.Errorf("%s: unexpected SecondaryKey flag", base) - } - } - // Distinct section counts frozen from the snapshot, plus 0 for the - // empty ItemBonus and 1 for plain single-section tables. - for _, want := range []int{36, 33, 26, 22, 16, 9, 8, 3, 2, 1, 0} { - if !sectionCounts[want] { - t.Errorf("expected some table to have %d sections", want) - } - } -} - -func TestSpellEffectEncryptedSkip(t *testing.T) { - if _, err := os.Stat(db2Dir); os.IsNotExist(err) { - t.Skip("snapshot not present") - } - table, err := ReadFile(filepath.Join(db2Dir, "SpellEffect.db2")) - if err != nil { - t.Fatal(err) - } - if table.RecordsCount != 142756 { - t.Errorf("SpellEffect header record_count = %d, want 142756", table.RecordsCount) - } - if len(table.Sections) != 36 { - t.Errorf("SpellEffect sections = %d, want 36", len(table.Sections)) - } - if table.SkippedSections != 35 { - t.Errorf("SpellEffect skipped sections = %d, want 35", table.SkippedSections) - } - // C1 exact check: 142756 header records − 136 encrypted = 142620 emitted. - if got := len(table.rows); got != 142620 { - t.Errorf("SpellEffect decoded raw rows = %d, want 142620", got) - } -} - -func TestDecodeAllTables(t *testing.T) { - if _, err := os.Stat(dbdDir); os.IsNotExist(err) { - t.Skip("snapshot not present") - } - for _, name := range db2Files(t) { - base := strings.TrimSuffix(name, ".db2") - table, err := ReadFile(filepath.Join(db2Dir, name)) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - def, err := dbd.ReadFile(filepath.Join(dbdDir, base+".dbd"), true) - if err != nil { - t.Fatalf("%s: %v", base, err) - } - version, err := dbd.SelectVersion(def, snapshotBuild) - if err != nil { - t.Fatalf("%s: %v", base, err) - } - decoded, err := table.DecodeRows(def, version, snapshotBuild) - if err != nil { - t.Fatalf("%s: decode: %v", base, err) - } - if base != "ItemBonus" && len(decoded.Rows) == 0 { - t.Errorf("%s: decoded 0 rows", base) - } - t.Logf("%s: %d rows, %d cols, %d skipped sections", base, len(decoded.Rows), len(decoded.ColumnNames), table.SkippedSections) - } -}