Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions scripts/gen_txt_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions src/framework/api/classes/ClassRegistry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -63,6 +64,9 @@ void RegisterAllClasses(v8::Isolate* isolate, v8::Local<v8::ObjectTemplate> 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) {
Expand Down Expand Up @@ -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<v8::Object> CreateMeObject(v8::Isolate* isolate, v8::Local<v8::Context> context) {
Expand Down
136 changes: 136 additions & 0 deletions src/framework/api/classes/game/JSTxtTables.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#pragma once

#include <v8.h>

#include <cstdint>
#include <string_view>

#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<JSTxtTables, TxtTablesData> {
public:
static constexpr std::string_view ClassName = "TxtTables";
V8_CLASS_NOT_CONSTRUCTABLE

static void ConfigureTemplate(v8::Isolate* isolate, v8::Local<v8::FunctionTemplate> tpl) {
using namespace d2bs::api::globals; // ResolveTableArg / ResolveTxtColumns / ResolveTxtCell / BuildTxtRow

/// @description List every known data (.txt) table name.
/// @signature TxtTables.names()
/// @returns {Array<string>} - the table names, usable as the `table` argument to the other methods
StaticMethod(
isolate, tpl, "names", +[](const v8::FunctionCallbackInfo<v8::Value>& args) {
auto* isolate = args.GetIsolate();
auto context = isolate->GetCurrentContext();
auto arr = v8::Array::New(isolate, static_cast<int32_t>(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<v8::Value>& 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<string>|undefined} - the column names, or undefined if the table is unknown
StaticMethod(
isolate, tpl, "columns", +[](const v8::FunctionCallbackInfo<v8::Value>& 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<int32_t>(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<v8::Value>& 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<v8::Value>& 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
60 changes: 15 additions & 45 deletions src/framework/api/globals/GameFunctions.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -943,63 +944,32 @@ void RegisterGameFunctions(v8::Isolate* isolate, v8::Local<v8::ObjectTemplate> 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<v8::Value>& 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<d2bs::game::TxtValue> == 3,
"TxtValue alternatives changed - update the get_if chain below");
if (auto* n = std::get_if<int64_t>(&value)) {
args.GetReturnValue().Set(v8_convert::ToV8(isolate, static_cast<double>(*n)));
} else if (auto* s = std::get_if<std::string>(&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.
Expand Down
16 changes: 16 additions & 0 deletions src/framework/api/globals/TxtLookup.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include <cstdint>
#include <optional>
#include <span>
#include <string>
#include <string_view>

Expand Down Expand Up @@ -40,4 +41,19 @@ inline std::optional<std::string_view> 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<std::span<const std::string_view>> 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
93 changes: 93 additions & 0 deletions src/framework/api/globals/TxtTableAccess.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#pragma once

#include <v8.h>

#include <cstdint>
#include <optional>
#include <string>
#include <variant>

#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<std::string> ResolveTableArg(v8::Isolate* isolate, v8::Local<v8::Value> 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<v8::Value> TxtValueToV8(v8::Isolate* isolate, const d2bs::game::TxtValue& value) {
static_assert(std::variant_size_v<d2bs::game::TxtValue> == 3,
"TxtValue alternatives changed - update the conversion below");
if (const auto* n = std::get_if<int64_t>(&value)) {
return v8_convert::ToV8(isolate, static_cast<double>(*n));
}
if (const auto* s = std::get_if<std::string>(&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<v8::Value> ResolveTxtCell(v8::Isolate* isolate, const std::string& tableName, uint32_t row,
v8::Local<v8::Value> 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::Value>(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<v8::Value> BuildTxtRow(v8::Isolate* isolate, v8::Local<v8::Context> 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
Loading
Loading