From 56279f85c6f3b2e14655392b4b0e5cb3e285d11e Mon Sep 17 00:00:00 2001 From: Resurrected Trader Date: Wed, 17 Jun 2026 15:43:59 +0100 Subject: [PATCH] feat(txt): properties table + TxtTables namespace for the data tables Expose the Diablo II data (.txt) tables more fully. - properties.txt: add the property -> (func, stat, set, val) table that affixes / runewords / set bonuses resolve their mods through. Record layout verified against Game.exe (TXT_AllocTxt_properties, 0x2e-byte records) and D2MOO (D2PropertiesTxt; pointer/count at sgptDataTables +0xa4/+0xac). - TxtTables: a non-constructable namespace class (like HttpClient's static methods) with names(), size(table), columns(table), row(table, id) and value(table, id, column) - the same machinery as getBaseStat, with method names that don't stutter. - getBaseStat(table, row) with no column now returns the whole row as a {column: value} object. - game::GetTxtTableRowCount primitive backs size(); shared cell/row marshaling lives in api/globals/TxtTableAccess.h. - gen_txt_tables.py appends properties; TxtTables.h regenerated. --- scripts/gen_txt_tables.py | 17 +++ src/framework/api/classes/ClassRegistry.cpp | 7 + src/framework/api/classes/game/JSTxtTables.h | 136 +++++++++++++++++++ src/framework/api/globals/GameFunctions.cpp | 60 ++------ src/framework/api/globals/TxtLookup.h | 16 +++ src/framework/api/globals/TxtTableAccess.h | 93 +++++++++++++ src/framework/api/globals/TxtTables.h | 43 +++++- src/framework/framework.vcxproj | 2 + src/framework/game/GameHelpers.h | 4 + src/lod114d/game/GameHelpers.cpp | 7 + src/lod114d/imports/extras/MPQStats.cpp | 65 ++++++++- src/lod114d/imports/extras/MPQStats.h | 5 + 12 files changed, 406 insertions(+), 49 deletions(-) create mode 100644 src/framework/api/classes/game/JSTxtTables.h create mode 100644 src/framework/api/globals/TxtTableAccess.h diff --git a/scripts/gen_txt_tables.py b/scripts/gen_txt_tables.py index cdc38ce..91877ea 100644 --- a/scripts/gen_txt_tables.py +++ b/scripts/gen_txt_tables.py @@ -200,6 +200,23 @@ def main() -> int: ) ) + # properties.txt is likewise absent from the reference BaseStatTable; d2bsng's + # runtime reader sources it from Game.exe (TXT_AllocTxt_properties). Columns are + # listed in struct/offset order - wProp ('code'), then the nSet, wVal, nFunc, + # wStat arrays in turn - matching COLS_PROPERTIES in MPQStats.cpp. + tables.append( + ( + "properties", + [ + "code", + "set1", "set2", "set3", "set4", "set5", "set6", "set7", + "val1", "val2", "val3", "val4", "val5", "val6", "val7", + "func1", "func2", "func3", "func4", "func5", "func6", "func7", + "stat1", "stat2", "stat3", "stat4", "stat5", "stat6", "stat7", + ], + ) + ) + # Reference-path of the script as displayed in the header banner (forward slashes). script_rel = ( Path(__file__).resolve().relative_to(repo_root).as_posix() diff --git a/src/framework/api/classes/ClassRegistry.cpp b/src/framework/api/classes/ClassRegistry.cpp index cabb5a8..736f1e7 100644 --- a/src/framework/api/classes/ClassRegistry.cpp +++ b/src/framework/api/classes/ClassRegistry.cpp @@ -17,6 +17,7 @@ #include "game/JSParty.h" #include "game/JSPresetUnit.h" #include "game/JSRoom.h" +#include "game/JSTxtTables.h" #include "game/JSUnit.h" #include "io/JSDBStatement.h" #include "io/JSDirectory.h" @@ -63,6 +64,9 @@ void RegisterAllClasses(v8::Isolate* isolate, v8::Local glob global->Set(isolate, "Socket", JSSocket::GetTemplate(isolate)); global->Set(isolate, "SQLite", JSSQLite::GetTemplate(isolate)); global->Set(isolate, "DBStatement", JSDBStatement::GetTemplate(isolate)); + + // Game data tables (static namespace; not constructable) + global->Set(isolate, "TxtTables", JSTxtTables::GetTemplate(isolate)); } void ClearAllClassCaches(v8::Isolate* isolate) { @@ -97,6 +101,9 @@ void ClearAllClassCaches(v8::Isolate* isolate) { JSSocket::ClearCache(isolate); JSSQLite::ClearCache(isolate); JSDBStatement::ClearCache(isolate); + + // Game data tables + JSTxtTables::ClearCache(isolate); } v8::Local CreateMeObject(v8::Isolate* isolate, v8::Local context) { diff --git a/src/framework/api/classes/game/JSTxtTables.h b/src/framework/api/classes/game/JSTxtTables.h new file mode 100644 index 0000000..7eb6f30 --- /dev/null +++ b/src/framework/api/classes/game/JSTxtTables.h @@ -0,0 +1,136 @@ +#pragma once + +#include + +#include +#include + +#include "api/core/V8Class.h" +#include "api/core/V8Convert.h" +#include "api/core/V8Error.h" +#include "api/globals/TxtTableAccess.h" +#include "game/GameHelpers.h" + +namespace d2bs::api::classes { + +// Native payload for the TxtTables class. The class is a pure static namespace +// (never instantiated), so this carries no state - it exists only to satisfy +// the V8ClassBase NativeType parameter. +struct TxtTablesData {}; + +// `TxtTables`: a non-constructable namespace class exposing the Diablo II data +// (.txt) tables through static methods - the same machinery as the global +// getBaseStat, with method names that don't stutter. Used from scripts as +// `TxtTables.names()`, `TxtTables.row("runes", 42)`, etc. +class JSTxtTables : public V8ClassBase { + public: + static constexpr std::string_view ClassName = "TxtTables"; + V8_CLASS_NOT_CONSTRUCTABLE + + static void ConfigureTemplate(v8::Isolate* isolate, v8::Local tpl) { + using namespace d2bs::api::globals; // ResolveTableArg / ResolveTxtColumns / ResolveTxtCell / BuildTxtRow + + /// @description List every known data (.txt) table name. + /// @signature TxtTables.names() + /// @returns {Array} - the table names, usable as the `table` argument to the other methods + StaticMethod( + isolate, tpl, "names", +[](const v8::FunctionCallbackInfo& args) { + auto* isolate = args.GetIsolate(); + auto context = isolate->GetCurrentContext(); + auto arr = v8::Array::New(isolate, static_cast(d2bs::game::TXT_TABLE_NAMES.size())); + uint32_t i = 0; + for (const auto& name : d2bs::game::TXT_TABLE_NAMES) { + arr->Set(context, i++, v8_convert::ToV8(isolate, name)).Check(); + } + args.GetReturnValue().Set(arr); + }); + + /// @description Number of rows in a table. + /// @signature TxtTables.size(table) + /// @param table {string|number} - table name, or index into TxtTables.names() + /// @returns {number|undefined} - the row count, or undefined if the table is unknown or its data is not loaded + StaticMethod( + isolate, tpl, "size", +[](const v8::FunctionCallbackInfo& args) { + auto* isolate = args.GetIsolate(); + if (args.Length() < 1) { + return; + } + auto table = ResolveTableArg(isolate, args[0]); + if (!table) { + return; + } + if (auto count = d2bs::game::GetTxtTableRowCount(*table)) { + args.GetReturnValue().Set(v8_convert::ToV8(isolate, *count)); + } + }); + + /// @description List a table's column names, in column order. + /// @signature TxtTables.columns(table) + /// @param table {string|number} - table name, or index into TxtTables.names() + /// @returns {Array|undefined} - the column names, or undefined if the table is unknown + StaticMethod( + isolate, tpl, "columns", +[](const v8::FunctionCallbackInfo& args) { + auto* isolate = args.GetIsolate(); + auto context = isolate->GetCurrentContext(); + if (args.Length() < 1) { + return; + } + auto table = ResolveTableArg(isolate, args[0]); + if (!table) { + return; + } + auto columns = ResolveTxtColumns(*table); + if (!columns) { + return; + } + auto arr = v8::Array::New(isolate, static_cast(columns->size())); + uint32_t i = 0; + for (const auto& column : *columns) { + arr->Set(context, i++, v8_convert::ToV8(isolate, column)).Check(); + } + args.GetReturnValue().Set(arr); + }); + + /// @description Read a whole row as an object mapping each column name to its value. + /// @signature TxtTables.row(table, row) + /// @param table {string|number} - table name, or index into TxtTables.names() + /// @param row {number} - row index + /// @returns {object|undefined} - a {column: value} object, or undefined if the table is unknown or the row is + /// out of range + StaticMethod( + isolate, tpl, "row", +[](const v8::FunctionCallbackInfo& args) { + auto* isolate = args.GetIsolate(); + if (args.Length() < 2) { + return; + } + auto table = ResolveTableArg(isolate, args[0]); + if (!table) { + return; + } + uint32_t row = v8_convert::ToUint32(isolate, args[1]); + args.GetReturnValue().Set(BuildTxtRow(isolate, isolate->GetCurrentContext(), *table, row)); + }); + + /// @description Read a single cell (the same lookup as getBaseStat with a column). + /// @signature TxtTables.value(table, row, column) + /// @param table {string|number} - table name, or index into TxtTables.names() + /// @param row {number} - row index + /// @param column {string|number} - column name, or index into the table's columns + /// @returns {number|string|undefined} - the cell value, or undefined if unresolved or the cell is empty + StaticMethod( + isolate, tpl, "value", +[](const v8::FunctionCallbackInfo& args) { + auto* isolate = args.GetIsolate(); + if (args.Length() < 3) { + return; + } + auto table = ResolveTableArg(isolate, args[0]); + if (!table) { + return; + } + uint32_t row = v8_convert::ToUint32(isolate, args[1]); + args.GetReturnValue().Set(ResolveTxtCell(isolate, *table, row, args[2])); + }); + } +}; + +} // namespace d2bs::api::classes diff --git a/src/framework/api/globals/GameFunctions.cpp b/src/framework/api/globals/GameFunctions.cpp index b8d3438..fd44871 100644 --- a/src/framework/api/globals/GameFunctions.cpp +++ b/src/framework/api/globals/GameFunctions.cpp @@ -21,6 +21,7 @@ #include "api/core/V8Extract.h" #include "api/core/V8Function.h" #include "api/globals/TxtLookup.h" +#include "api/globals/TxtTableAccess.h" #include "components/config/AppConfig.h" #include "components/pathfinding/Pathfinder.h" #include "components/script/ScriptEngine.h" @@ -943,63 +944,32 @@ void RegisterGameFunctions(v8::Isolate* isolate, v8::Local g args.GetReturnValue().Set(obj); }); - /// @description Read a value from a game data (.txt) table by table, row, and column. - /// @signature getBaseStat(table: string, row: number, column: string) - /// @signature getBaseStat(table: number, row: number, column: number) + /// @description Read game data (.txt) table cells. With a column, returns that one cell; without a column, + /// returns an object mapping every column name to its value for the row. + /// @signature getBaseStat(table: string|number, row: number, column: string|number) + /// @signature getBaseStat(table: string|number, row: number) /// @param table {string|number} - table name, or index into the known table-name list /// @param row {number} - row index - /// @param column {string|number} - column name, or index into the resolved table's columns - /// @returns {number|string|undefined} - the cell value, or undefined if unresolved or the cell is empty + /// @param column {string|number} - column name, or index into the resolved table's columns; omit for the whole row + /// @returns {number|string|object|undefined} - the cell value; a {column: value} object when column is omitted; + /// undefined if unresolved, the row is out of range, or the cell is empty v8_function::Register( isolate, global, "getBaseStat", +[](const v8::FunctionCallbackInfo& args) { auto* isolate = args.GetIsolate(); - - if (args.Length() < 3) { + if (args.Length() < 2) { return; } - - // Table arg: string (canonical name) or number (index into TXT_TABLE_NAMES). - std::string tableName; - if (args[0]->IsString()) { - tableName = v8_convert::ToString(isolate, args[0]); - } else if (args[0]->IsNumber()) { - auto resolved = ResolveTxtTable(v8_convert::ToUint32(isolate, args[0])); - if (!resolved) { - return; - } - tableName = std::string(*resolved); - } else { + auto table = ResolveTableArg(isolate, args[0]); + if (!table) { return; } - uint32_t row = v8_convert::ToUint32(isolate, args[1]); - - // Column arg: string (canonical name) or number (index into the table's columns). - std::string columnName; - if (args[2]->IsString()) { - columnName = v8_convert::ToString(isolate, args[2]); - } else if (args[2]->IsNumber()) { - auto resolved = ResolveTxtColumn(tableName, v8_convert::ToUint32(isolate, args[2])); - if (!resolved) { - return; - } - columnName = std::string(*resolved); - } else { + // No column arg (or explicit undefined) -> whole-row object. + if (args.Length() < 3 || args[2]->IsUndefined()) { + args.GetReturnValue().Set(BuildTxtRow(isolate, isolate->GetCurrentContext(), *table, row)); return; } - - auto value = d2bs::game::GetTxtValue(tableName, row, columnName); - // Guardrail: if a new alternative is added to TxtValue, this get_if chain must be - // extended to avoid silently dropping values. Bump the count + add a branch below. - static_assert(std::variant_size_v == 3, - "TxtValue alternatives changed - update the get_if chain below"); - if (auto* n = std::get_if(&value)) { - args.GetReturnValue().Set(v8_convert::ToV8(isolate, static_cast(*n))); - } else if (auto* s = std::get_if(&value)) { - args.GetReturnValue().Set(v8_convert::ToV8(isolate, *s)); - } else { - args.GetReturnValue().SetUndefined(); - } + args.GetReturnValue().Set(ResolveTxtCell(isolate, *table, row, args[2])); }); /// @description Find the first UI control matching optional position/size filters; menu state only. diff --git a/src/framework/api/globals/TxtLookup.h b/src/framework/api/globals/TxtLookup.h index 1ffead8..f5b4938 100644 --- a/src/framework/api/globals/TxtLookup.h +++ b/src/framework/api/globals/TxtLookup.h @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -40,4 +41,19 @@ inline std::optional ResolveTxtColumn(std::string_view tableNa return std::nullopt; } +// Full ordered column-name span for a table, or nullopt if the table name is +// unknown. Case-insensitive name match (as ResolveTxtColumn). Backs the +// TxtTables.columns()/row() bindings and the getBaseStat whole-row form. +inline std::optional> ResolveTxtColumns(std::string_view tableName) { + std::string lowered = d2bs::utils::ToLower(std::string(tableName)); + // NOLINTBEGIN(cppcoreguidelines-pro-bounds-constant-array-index) - parallel-array lookup by runtime name match + for (size_t i = 0; i < d2bs::game::TXT_TABLE_NAMES.size(); ++i) { + if (d2bs::game::TXT_TABLE_NAMES[i] == lowered) { + return d2bs::game::TXT_COLUMNS_BY_TABLE[i]; + } + } + // NOLINTEND(cppcoreguidelines-pro-bounds-constant-array-index) + return std::nullopt; +} + } // namespace d2bs::api::globals diff --git a/src/framework/api/globals/TxtTableAccess.h b/src/framework/api/globals/TxtTableAccess.h new file mode 100644 index 0000000..040a71b --- /dev/null +++ b/src/framework/api/globals/TxtTableAccess.h @@ -0,0 +1,93 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include "api/core/V8Convert.h" +#include "api/globals/TxtLookup.h" +#include "game/GameHelpers.h" + +// Shared marshaling for the .txt-table JS surface (the global getBaseStat and +// the TxtTables class bind to these). Resolution is name- or index-based and +// tolerant of bad args: callers map nullopt / empty handles to a JS undefined. + +namespace d2bs::api::globals { + +// Resolve a table arg to a canonical name: a string name, or a number indexing +// TXT_TABLE_NAMES. nullopt when the arg is the wrong type or the index is out of range. +inline std::optional ResolveTableArg(v8::Isolate* isolate, v8::Local arg) { + if (arg->IsString()) { + return v8_convert::ToString(isolate, arg); + } + if (arg->IsNumber()) { + if (auto resolved = ResolveTxtTable(v8_convert::ToUint32(isolate, arg))) { + return std::string(*resolved); + } + } + return std::nullopt; +} + +// Convert one resolved cell to a JS value. Returns an empty handle for an empty +// / unsupported cell (monostate) so callers can map it to undefined or omit it. +inline v8::Local TxtValueToV8(v8::Isolate* isolate, const d2bs::game::TxtValue& value) { + static_assert(std::variant_size_v == 3, + "TxtValue alternatives changed - update the conversion below"); + if (const auto* n = std::get_if(&value)) { + return v8_convert::ToV8(isolate, static_cast(*n)); + } + if (const auto* s = std::get_if(&value)) { + return v8_convert::ToV8(isolate, *s); + } + return {}; +} + +// Resolve a single cell from an already-resolved table name + row and a column +// arg (string name or numeric index). Undefined for a bad column arg / +// unresolved index / empty cell. Shared by getBaseStat (3-arg) and TxtTables.value. +inline v8::Local ResolveTxtCell(v8::Isolate* isolate, const std::string& tableName, uint32_t row, + v8::Local columnArg) { + std::string columnName; + if (columnArg->IsString()) { + columnName = v8_convert::ToString(isolate, columnArg); + } else if (columnArg->IsNumber()) { + auto resolved = ResolveTxtColumn(tableName, v8_convert::ToUint32(isolate, columnArg)); + if (!resolved) { + return v8::Undefined(isolate); + } + columnName = std::string(*resolved); + } else { + return v8::Undefined(isolate); + } + auto cell = TxtValueToV8(isolate, d2bs::game::GetTxtValue(tableName, row, columnName)); + return cell.IsEmpty() ? v8::Local(v8::Undefined(isolate)) : cell; +} + +// Build a {column: value} object for one table row, or undefined if the table +// is unknown or `row` is past the live row count (so an out-of-range id yields +// undefined rather than an all-empty object). Empty / unsupported cells are +// omitted. Shared by getBaseStat's 2-arg form and TxtTables.row. +inline v8::Local BuildTxtRow(v8::Isolate* isolate, v8::Local context, + const std::string& tableName, uint32_t row) { + auto columns = ResolveTxtColumns(tableName); + if (!columns) { + return v8::Undefined(isolate); + } + auto rowCount = d2bs::game::GetTxtTableRowCount(tableName); + if (!rowCount || row >= *rowCount) { + return v8::Undefined(isolate); + } + auto obj = v8::Object::New(isolate); + for (const auto& column : *columns) { + auto cell = TxtValueToV8(isolate, d2bs::game::GetTxtValue(tableName, row, column)); + if (!cell.IsEmpty()) { + obj->Set(context, v8_convert::ToV8(isolate, column), cell).Check(); + } + } + return obj; +} + +} // namespace d2bs::api::globals diff --git a/src/framework/api/globals/TxtTables.h b/src/framework/api/globals/TxtTables.h index fe2c3eb..8603393 100644 --- a/src/framework/api/globals/TxtTables.h +++ b/src/framework/api/globals/TxtTables.h @@ -4,7 +4,7 @@ // Schema of the Diablo II 1.14d data tables (.txt): table index -> name, // and per-table column index -> name. Includes the appended 'affixes' table // (magicprefix / magicsuffix). -// Summary: 27 tables, 2268 columns total. +// Summary: 28 tables, 2297 columns total. #pragma once #include @@ -16,7 +16,7 @@ namespace d2bs::game { // ============================================================================ // Table name lookup: table index -> canonical .txt table name. // ============================================================================ -inline constexpr std::array TXT_TABLE_NAMES = { +inline constexpr std::array TXT_TABLE_NAMES = { "items", // [ 0] "monstats", // [ 1] "skilldesc", // [ 2] @@ -44,6 +44,7 @@ inline constexpr std::array TXT_TABLE_NAMES = { "pettable", // [24] "superuniques", // [25] "affixes", // [26] + "properties", // [27] }; // ---------------------------------------------------------------------------- @@ -2476,11 +2477,46 @@ inline constexpr std::array TXT_COLUMNS_AFFIXES = { "add", // [40] }; +// ---------------------------------------------------------------------------- +// [27] properties - 29 columns +// ---------------------------------------------------------------------------- +inline constexpr std::array TXT_COLUMNS_PROPERTIES = { + "code", // [ 0] + "set1", // [ 1] + "set2", // [ 2] + "set3", // [ 3] + "set4", // [ 4] + "set5", // [ 5] + "set6", // [ 6] + "set7", // [ 7] + "val1", // [ 8] + "val2", // [ 9] + "val3", // [10] + "val4", // [11] + "val5", // [12] + "val6", // [13] + "val7", // [14] + "func1", // [15] + "func2", // [16] + "func3", // [17] + "func4", // [18] + "func5", // [19] + "func6", // [20] + "func7", // [21] + "stat1", // [22] + "stat2", // [23] + "stat3", // [24] + "stat4", // [25] + "stat5", // [26] + "stat6", // [27] + "stat7", // [28] +}; + // ============================================================================ // Column lookup: table index -> span of that table's column names. // Use together with TXT_TABLE_NAMES to resolve (tableIdx, colIdx) -> names. // ============================================================================ -inline constexpr std::array, 27> TXT_COLUMNS_BY_TABLE = { +inline constexpr std::array, 28> TXT_COLUMNS_BY_TABLE = { std::span{TXT_COLUMNS_ITEMS}, // [ 0] items std::span{TXT_COLUMNS_MONSTATS}, // [ 1] monstats std::span{TXT_COLUMNS_SKILLDESC}, // [ 2] skilldesc @@ -2508,6 +2544,7 @@ inline constexpr std::array, 27> TXT_COLUMNS_B std::span{TXT_COLUMNS_PETTABLE}, // [24] pettable std::span{TXT_COLUMNS_SUPERUNIQUES}, // [25] superuniques std::span{TXT_COLUMNS_AFFIXES}, // [26] affixes + std::span{TXT_COLUMNS_PROPERTIES}, // [27] properties }; } // namespace d2bs::game diff --git a/src/framework/framework.vcxproj b/src/framework/framework.vcxproj index 8b45e3c..15defc3 100644 --- a/src/framework/framework.vcxproj +++ b/src/framework/framework.vcxproj @@ -196,6 +196,7 @@ + @@ -204,6 +205,7 @@ + diff --git a/src/framework/game/GameHelpers.h b/src/framework/game/GameHelpers.h index d9a3fe9..b333ec7 100644 --- a/src/framework/game/GameHelpers.h +++ b/src/framework/game/GameHelpers.h @@ -115,6 +115,10 @@ using TxtValue = std::variant; TxtValue GetTxtValue(std::string_view table, uint32_t row, std::string_view column); +// Number of rows in the named .txt table, or nullopt if the table name is +// unknown or its game data is not currently loaded (e.g. out of game). +std::optional GetTxtTableRowCount(std::string_view table); + int32_t GetQuestFlag(uint32_t quest, uint32_t flag); // === Weapon / Stat / Skill Actions === void SwapWeapon(); diff --git a/src/lod114d/game/GameHelpers.cpp b/src/lod114d/game/GameHelpers.cpp index 6daae07..90e8130 100644 --- a/src/lod114d/game/GameHelpers.cpp +++ b/src/lod114d/game/GameHelpers.cpp @@ -822,6 +822,13 @@ TxtValue GetTxtValue(std::string_view table, uint32_t row, std::string_view colu return std::monostate{}; } +std::optional GetTxtTableRowCount(std::string_view table) { + if (table.empty()) { + return std::nullopt; + } + return imports::extras::GetTxtTableRowCount(table); +} + int32_t GetQuestFlag(uint32_t quest, uint32_t flag) { return d2common::QUESTRECORD_GetQuestFlag(d2client::QUESTRECORD_GetQuestInfo(), quest, flag); } diff --git a/src/lod114d/imports/extras/MPQStats.cpp b/src/lod114d/imports/extras/MPQStats.cpp index 796b76c..12c94cf 100644 --- a/src/lod114d/imports/extras/MPQStats.cpp +++ b/src/lod114d/imports/extras/MPQStats.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -2389,7 +2390,48 @@ constexpr std::array COLS_AFFIXES = {{ {.name = "add", .type = FieldKind::Dword, .bitOrLen = 0x0U, .offset = 0x8cU}, }}; -constexpr std::array TABLES = {{ +// properties.txt - the property -> (set, val, func, stat) mapping affixes / +// runewords / set bonuses resolve their mods through. Record layout verified +// against Game.exe TXT_AllocTxt_properties (column descriptors + record size +// 0x2e) and D2MOO's D2PropertiesTxt (nSet[7]@0x02, wVal[7]@0x0a, nFunc[7]@0x18, +// wStat[7]@0x20). Columns are listed in struct/offset order - wProp, then the +// nSet, wVal, nFunc, wStat arrays in turn - matching the offset-ascending layout +// of the other tables here; the pad bytes at 0x09 and 0x1f are not columns and +// are skipped. 'code' is the wProp word at 0x00 (the property's name resolved to +// its index; NAME_TO_INDEX in the game), so it reads as a number, not the name. +constexpr std::array COLS_PROPERTIES = {{ + {.name = "code", .type = FieldKind::Word, .bitOrLen = 0x0U, .offset = 0x00U}, + {.name = "set1", .type = FieldKind::Byte, .bitOrLen = 0x0U, .offset = 0x02U}, + {.name = "set2", .type = FieldKind::Byte, .bitOrLen = 0x0U, .offset = 0x03U}, + {.name = "set3", .type = FieldKind::Byte, .bitOrLen = 0x0U, .offset = 0x04U}, + {.name = "set4", .type = FieldKind::Byte, .bitOrLen = 0x0U, .offset = 0x05U}, + {.name = "set5", .type = FieldKind::Byte, .bitOrLen = 0x0U, .offset = 0x06U}, + {.name = "set6", .type = FieldKind::Byte, .bitOrLen = 0x0U, .offset = 0x07U}, + {.name = "set7", .type = FieldKind::Byte, .bitOrLen = 0x0U, .offset = 0x08U}, + {.name = "val1", .type = FieldKind::Word, .bitOrLen = 0x0U, .offset = 0x0aU}, + {.name = "val2", .type = FieldKind::Word, .bitOrLen = 0x0U, .offset = 0x0cU}, + {.name = "val3", .type = FieldKind::Word, .bitOrLen = 0x0U, .offset = 0x0eU}, + {.name = "val4", .type = FieldKind::Word, .bitOrLen = 0x0U, .offset = 0x10U}, + {.name = "val5", .type = FieldKind::Word, .bitOrLen = 0x0U, .offset = 0x12U}, + {.name = "val6", .type = FieldKind::Word, .bitOrLen = 0x0U, .offset = 0x14U}, + {.name = "val7", .type = FieldKind::Word, .bitOrLen = 0x0U, .offset = 0x16U}, + {.name = "func1", .type = FieldKind::Byte, .bitOrLen = 0x0U, .offset = 0x18U}, + {.name = "func2", .type = FieldKind::Byte, .bitOrLen = 0x0U, .offset = 0x19U}, + {.name = "func3", .type = FieldKind::Byte, .bitOrLen = 0x0U, .offset = 0x1aU}, + {.name = "func4", .type = FieldKind::Byte, .bitOrLen = 0x0U, .offset = 0x1bU}, + {.name = "func5", .type = FieldKind::Byte, .bitOrLen = 0x0U, .offset = 0x1cU}, + {.name = "func6", .type = FieldKind::Byte, .bitOrLen = 0x0U, .offset = 0x1dU}, + {.name = "func7", .type = FieldKind::Byte, .bitOrLen = 0x0U, .offset = 0x1eU}, + {.name = "stat1", .type = FieldKind::Word, .bitOrLen = 0x0U, .offset = 0x20U}, + {.name = "stat2", .type = FieldKind::Word, .bitOrLen = 0x0U, .offset = 0x22U}, + {.name = "stat3", .type = FieldKind::Word, .bitOrLen = 0x0U, .offset = 0x24U}, + {.name = "stat4", .type = FieldKind::Word, .bitOrLen = 0x0U, .offset = 0x26U}, + {.name = "stat5", .type = FieldKind::Word, .bitOrLen = 0x0U, .offset = 0x28U}, + {.name = "stat6", .type = FieldKind::Word, .bitOrLen = 0x0U, .offset = 0x2aU}, + {.name = "stat7", .type = FieldKind::Word, .bitOrLen = 0x0U, .offset = 0x2cU}, +}}; + +constexpr std::array TABLES = {{ {.name = "items", .info = {.tableOffset = 0x4426f0U, .countOffset = 0x4426ecU, .isAbsolute = true, .recordSize = 0x1a8U}, .columns = COLS_ITEMS}, @@ -2473,6 +2515,12 @@ constexpr std::array TABLES = {{ {.name = "affixes", .info = {.tableOffset = 0x56ca80U, .countOffset = 0x56ca7cU, .isAbsolute = true, .recordSize = 0x90U}, .columns = COLS_AFFIXES}, + // properties.txt: pointer + count live in *sgptDataTables (D2MOO + // D2DataTblsStrc::pPropertiesTxt @ 0xa4, nPropertiesTxtRecordCount @ 0xac), + // 0-based, 0x2e-byte records (Game.exe TXT_AllocTxt_properties). + {.name = "properties", + .info = {.tableOffset = 0xa4U, .countOffset = 0xacU, .isAbsolute = false, .recordSize = 0x2eU}, + .columns = COLS_PROPERTIES}, }}; // === Lookup helpers ========================================================= @@ -2678,4 +2726,19 @@ TxtValue GetTxtValue(std::string_view tableName, uint32_t recordId, std::string_ return ReadField(record, *col); } +std::optional GetTxtTableRowCount(std::string_view tableName) { + const TableSchema* table = FindTable(tableName); + if (table == nullptr) { + return std::nullopt; + } + const uint8_t* base = nullptr; + uint32_t count = 0; + ResolveTableBase(table->info, &base, &count); + if (base == nullptr) { + // Data table not loaded yet (out of game, or queried too early). + return std::nullopt; + } + return count; +} + } // namespace d2bs::imports::extras diff --git a/src/lod114d/imports/extras/MPQStats.h b/src/lod114d/imports/extras/MPQStats.h index f64adba..b1eef4d 100644 --- a/src/lod114d/imports/extras/MPQStats.h +++ b/src/lod114d/imports/extras/MPQStats.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -24,4 +25,8 @@ using TxtValue = std::variant; [[nodiscard]] TxtValue GetTxtValue(std::string_view tableName, uint32_t recordId, std::string_view columnName); +// Number of rows in the named table. nullopt when the table name is unknown or +// the underlying game data table is not yet loaded. +[[nodiscard]] std::optional GetTxtTableRowCount(std::string_view tableName); + } // namespace d2bs::imports::extras