Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,12 @@ tables. It adds the pair's include to every header that defines an enumeration a
lists the pair in the `.vcxproj`; `.gitattributes` marks the pairs generated, so GitHub
collapses them in diffs. The shared lookup and the `std::formatter` live in
`utils/EnumNaming.h`.
An enumeration marked `/// @flags` (the same marker the API docs read) also gets `constexpr`
`|`, `&`, `^`, `~`, `|=`, `&=`, `^=` and `HasFlag(value, flag)` (every bit of `flag`) /
`HasAnyFlag(value, flag)`, in its own namespace so argument-dependent lookup finds them; they
forward to `utils/EnumFlags.h`. A flag set so stays its enumeration type - test bits with
`HasFlag`, not `std::to_underlying(a) & std::to_underlying(b)`. Only scoped enumerations may
be marked (operators on an unscoped one would change what existing expressions mean).
**Rerun the script after adding, removing or changing an enumeration** (`pip install
libclang` once); CI's `--check` fails otherwise. A covered enumeration must be
forward-declarable - scoped, or unscoped with a fixed underlying type - and that type
Expand Down
53 changes: 50 additions & 3 deletions scripts/gen_enum_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
declares, in each enumeration's own namespace,
EnumName(value) and format_as(value) (fmt's hook);
includes utils/EnumNaming.h for the std::formatter.
An enumeration marked `/// @flags` (a comment line in
the block right above it) also gets constexpr |, &, ^,
~, |=, &=, ^= and HasFlag / HasAnyFlag, forwarding to
utils/EnumFlags.h, so a flag set keeps its type.
Every header that defines one of the enumerations
includes it, so an enumeration cannot be used without
its names - and, being only declarations, it is cheap.
Expand Down Expand Up @@ -61,6 +65,8 @@
VENDORED = [] # e.g. SRC / "some" / "third_party" - paths under src/ to leave alone
SUFFIX = "EnumNames"
SHARED_HEADER = "utils/EnumNaming.h"
FLAGS_HEADER = "utils/EnumFlags.h"
FLAGS_MARKER = "@flags"
STD_INTS = {f"{s}int{n}_t" for s in ("", "u") for n in (8, 16, 32, 64)}
BUILTIN_TYPE_WORDS = {"char", "short", "int", "long", "signed", "unsigned", "bool", "wchar_t", "char8_t",
"char16_t", "char32_t"}
Expand Down Expand Up @@ -152,6 +158,17 @@ def type_header(spelling):
raise RuntimeError(f"underlying type '{spelling}' is not a builtin or standard integer type")


def is_flags(lines, line):
"""Whether the `///` doc lines directly above 1-based *line* carry @flags - the rule
extract_api.py's _enum_is_flags applies, so the docs and the operators agree."""
i = line - 2
while i >= 0 and lines[i].strip().startswith("///"):
if FLAGS_MARKER in lines[i]:
return True
i -= 1
return False


def parse_project(project, headers):
"""{header path: [enumeration records]} for the namespace-scope enumerations of *headers*."""
source = "".join(f'#include "{project.spell(h)}"\n' for h in headers)
Expand All @@ -171,6 +188,7 @@ def parse_project(project, headers):
raise RuntimeError(f"libclang could not parse {project.vcxproj.name}'s headers:\n " + "\n ".join(fatal))

wanted = {h.resolve(): h for h in headers}
lines = {h: h.read_text(encoding="utf-8", errors="replace").splitlines() for h in headers}
found = {h: [] for h in headers}
seen = set()
for cursor in tu.cursor.walk_preorder():
Expand Down Expand Up @@ -199,9 +217,13 @@ def parse_project(project, headers):
include = type_header(underlying) if underlying else None
except RuntimeError as err:
raise RuntimeError(f"{project.spell(header)}: {qualified}: {err}") from None
flags = is_flags(lines[header], cursor.extent.start.line)
if flags and not scoped:
raise RuntimeError(f"{project.spell(header)}: {qualified} is marked @flags but unscoped - its "
"operators would change what existing expressions over it mean")
found[header].append({"name": cursor.spelling, "namespace": "::".join(scopes),
"enumerators": enumerators, "scoped": scoped, "underlying": underlying,
"include": include})
"include": include, "flags": flags})
return {h: e for h, e in found.items() if e}


Expand All @@ -227,10 +249,27 @@ def forward_declaration(e):
return f"{keyword} {e['name']}" + (f" : {e['underlying']};" if e["underlying"] else ";")


def flag_operators(e, u):
"""The generated operator set for a @flags enumeration *e*, in its own namespace."""
n, attrs = e["name"], "[[nodiscard, gnu::always_inline]] constexpr"
return [
f"{attrs} {n} operator|({n} lhs, {n} rhs) {{ return {u}FlagOr(lhs, rhs); }}",
f"{attrs} {n} operator&({n} lhs, {n} rhs) {{ return {u}FlagAnd(lhs, rhs); }}",
f"{attrs} {n} operator^({n} lhs, {n} rhs) {{ return {u}FlagXor(lhs, rhs); }}",
f"{attrs} {n} operator~({n} value) {{ return {u}FlagNot(value); }}",
f"[[gnu::always_inline]] constexpr {n}& operator|=({n}& lhs, {n} rhs) {{ return lhs = lhs | rhs; }}",
f"[[gnu::always_inline]] constexpr {n}& operator&=({n}& lhs, {n} rhs) {{ return lhs = lhs & rhs; }}",
f"[[gnu::always_inline]] constexpr {n}& operator^=({n}& lhs, {n} rhs) {{ return lhs = lhs ^ rhs; }}",
f"{attrs} bool HasFlag({n} value, {n} flag) {{ return {u}FlagHas(value, flag); }}",
f"{attrs} bool HasAnyFlag({n} value, {n} flag) {{ return {u}FlagHasAny(value, flag); }}",
]


def render_header(enums):
lines = [GENERATED, "", "#pragma once", ""]
lines += [f"#include {h}" for h in sorted({e["include"] for e in enums if e["include"]} | {"<string>"})]
lines += ["", f'#include "{SHARED_HEADER}"']
shared = [SHARED_HEADER] + ([FLAGS_HEADER] if any(e["flags"] for e in enums) else [])
lines += [""] + [f'#include "{h}"' for h in sorted(shared)]
for namespace, members in by_namespace(enums).items():
lines += ["", f"namespace {namespace} {{", ""]
lines += [forward_declaration(e) for e in members]
Expand All @@ -243,7 +282,15 @@ def render_header(enums):
lines += [f"[[nodiscard]] std::string EnumName({e['name']} value);" for e in members]
lines += ["", "// NOLINTBEGIN(readability-identifier-naming) - fmt's customisation point name"]
lines += [f"[[nodiscard]] std::string format_as({e['name']} value);" for e in members]
lines += ["// NOLINTEND(readability-identifier-naming)", "", f"}} // namespace {namespace}"]
lines += ["// NOLINTEND(readability-identifier-naming)"]
flagged = [e for e in members if e["flags"]]
if flagged:
u = utils_prefix(namespace)
lines += ["", "// Bit operations over the @flags enumerations: a value is an OR of their bits, and stays",
"// its enumeration type through these. HasFlag needs every bit of `flag`, HasAnyFlag one."]
for e in flagged:
lines += [""] + flag_operators(e, u)
lines += ["", f"}} // namespace {namespace}"]
lines.append("")
return "\n".join(lines)

Expand Down
11 changes: 5 additions & 6 deletions src/backends/lod114d/game/Control.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,16 @@ Rect Control::Bounds() const {
return ctrl ? ctrl->rect : Rect::Zero;
}

// Raw control state (dwState): 0x0D = difficulty button enabled, 0x04 = Bnet
// diff unavailable, etc. The JS-visible "state" (this minus 2) and "disabled"
// (raw) views are derived in the bindings.
uint32_t Control::State() const {
// The JS-visible "state" (this minus 2) and "disabled" (raw) views are derived in
// the bindings.
ControlState Control::State() const {
const auto ctrl = Resolve<D2WinControlStrc>();
return ctrl ? ctrl->dwState : 0U;
return ctrl ? ctrl->dwState : ControlState{};
}

// Reference's setter memset's the field with a single repeated byte (buggy:
// fills 0x0D0D0D0D etc.); write the integer directly so State() round-trips.
void Control::SetState(uint32_t value) const {
void Control::SetState(ControlState value) const {
const auto ctrl = Resolve<D2WinControlStrc>();
if (!ctrl) {
return;
Expand Down
4 changes: 2 additions & 2 deletions src/backends/lod114d/game/DrlgHelpers.h
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,13 @@ inline D2DrlgRoomStrc* FindRoomInLevelByPos(D2DrlgLevelStrc* lvl, Position pos)
// Returns the chosen cell number for `preset`, or -1 to skip.
inline int32_t PickPresetCellNo(const lod114d::imports::extras::D2PresetUnitStrc* preset, uint32_t levelNo) {
int32_t cell = -1;
if (preset->nUnitType == 1) { // Special NPCs
if (preset->nUnitType == UnitType::Monster) { // Special NPCs
if (preset->nIndex == 256) {
cell = 300; // Izzy
} else if (preset->nIndex == 745) {
cell = 745; // Hephasto
}
} else if (preset->nUnitType == 2) { // Objects
} else if (preset->nUnitType == UnitType::Object) {
if (preset->nIndex == 580 && levelNo == 79) {
cell = 318; // Lower Kurast uberchest hint
} else if (preset->nIndex == 371) {
Expand Down
36 changes: 22 additions & 14 deletions src/backends/lod114d/game/GameHelpers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -343,9 +343,9 @@ Difficulty GetMaxDiff() {
return data != nullptr ? static_cast<Difficulty>(data->nMaxDiff) : Difficulty::Normal;
}

uint32_t GetCharFlags() {
CharFlag GetCharFlags() {
auto* data = *d2launch::gpBnetData;
return data != nullptr ? static_cast<uint32_t>(data->nCharFlags) : 0U;
return data != nullptr ? static_cast<CharFlag>(data->nCharFlags) : CharFlag{};
}

std::optional<uint8_t> IsLadder() {
Expand Down Expand Up @@ -423,8 +423,14 @@ Point AbsScreenToMap(Point p) {

// === UI ===

bool GetUIFlag(uint32_t flag) {
return d2client::UI_GetVar(flag) != 0;
static_assert(std::to_underlying(UiFlag::Automap) == UI_AUTOMAP);
static_assert(std::to_underlying(UiFlag::NpcShop) == UI_NPCSHOP);
static_assert(std::to_underlying(UiFlag::Cube) == UI_CUBE);
static_assert(std::to_underlying(UiFlag::MercInventory) == UI_MERCINV);
static_assert(std::to_underlying(UiFlag::RecipeScroll) == UI_RECIPESCROLL);

bool GetUIFlag(UiFlag flag) {
return d2client::UI_GetVar(std::to_underlying(flag)) != 0;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prototype could take enum?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 7a11de5: UI_GetVar takes UiFlag, and the backend's own callers moved from D2MOO's UI_* ids to UiFlag::Cube / Game / NpcShop (the values are asserted equal to D2MOO's).

}

// === Text Rendering ===
Expand Down Expand Up @@ -815,8 +821,8 @@ void Transmute() {
});
}

bool TestPvpFlag(const Unit& a, const Unit& b, uint32_t flag) {
return d2client::PLAYERLIST_CheckFlag(a.Id(), b.Id(), flag) != 0;
bool TestPvpFlag(const Unit& a, const Unit& b, RosterFlag flag) {
return d2client::PLAYERLIST_CheckFlag(a.Id(), b.Id(), std::to_underlying(flag)) != 0;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prototype could take enum?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 7a11de5: PLAYERLIST_CheckFlag takes RosterFlag.

}

bool HasWaypoint(uint32_t waypointId) {
Expand Down Expand Up @@ -859,17 +865,20 @@ std::optional<uint32_t> GetTxtTableRowCount(std::string_view table) {
return extras::GetTxtTableRowCount(table);
}

int32_t GetQuestFlag(uint32_t quest, uint32_t flag) {
return d2common::QUESTRECORD_GetQuestFlag(d2client::QUESTRECORD_GetQuestInfo(), quest, flag);
static_assert(std::to_underlying(QuestFlag::RewardGranted) == QFLAG_REWARDGRANTED);
static_assert(std::to_underlying(QuestFlag::Started) == QFLAG_STARTED);
static_assert(std::to_underlying(QuestFlag::CompletedBefore) == QFLAG_COMPLETEDBEFORE);

int32_t GetQuestFlag(uint32_t quest, QuestFlag flag) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could there be a better return value based on what the runctikn does? Also prototype could take enum?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 7a11de5. The game function is a single-bit test (D2MOO's QUESTRECORD_GetQuestState returns BITMANIP_GetBitState(...) != 0), so GetQuestFlag now returns bool and the import takes QuestFlag. getQuest still returns 0 / 1 to scripts, as the reference returns a number.

return d2common::QUESTRECORD_GetQuestFlag(d2client::QUESTRECORD_GetQuestInfo(), quest, std::to_underlying(flag));
}

// === Weapon / Stat / Skill Actions ===

// Reference JSGame.cpp:1239-1251 -- packet 0x60. Gated by EXPAC because LoD
// classes are the only ones with a secondary weapon set.
void SwapWeapon() {
auto* data = *d2launch::gpBnetData;
if (data == nullptr || (data->nCharFlags & std::to_underlying(CharFlag::Expansion)) == 0) {
if (!HasFlag(GetCharFlags(), CharFlag::Expansion)) {
return;
}
std::array<uint8_t, 1> packet = {0x60};
Expand Down Expand Up @@ -1409,8 +1418,7 @@ void ClickPartyMember(const Party& party, PartyMode mode) {
switch (mode) {
case PartyMode::AllowLoot: {
// Loot only applies on hardcore.
auto* data = *d2launch::gpBnetData;
if (data == nullptr || (data->nCharFlags & std::to_underlying(CharFlag::Hardcore)) == 0) {
if (!HasFlag(GetCharFlags(), CharFlag::Hardcore)) {
return;
}
lod114d::asm_thunks::HostilePartyUnit(rosterPtr, 2U);
Expand Down Expand Up @@ -1446,7 +1454,7 @@ void LeaveParty() {
d2client::PARTY_Leave();
}

uint32_t CheckUnitCollision(const Unit& unit1, const Unit& unit2, uint32_t mask) {
uint32_t CheckUnitCollision(const Unit& unit1, const Unit& unit2, CollisionFlag mask) {
const auto id1 = unit1.Id();
const auto type1 = unit1.Type();
const auto id2 = unit2.Id();
Expand All @@ -1463,7 +1471,7 @@ uint32_t CheckUnitCollision(const Unit& unit1, const Unit& unit2, uint32_t mask)
if (p1 == nullptr || p2 == nullptr) {
return 0;
}
return d2common::UNITS_TestCollisionWithUnit(p1, p2, mask);
return d2common::UNITS_TestCollisionWithUnit(p1, p2, std::to_underlying(mask));

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prototype could take enum

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept uint32_t at this boundary on purpose. CollisionFlag is 16 bits, while the game reads a full 32-bit argument slot, and under the Windows calling conventions the bits above a narrower argument are unspecified - typing the prototype as CollisionFlag could hand the game garbage in the upper half. The call converts with std::to_underlying instead. The other three imports you flagged are 32-bit enums, so those now take the enum (7a11de5).

}

// === Skill Name Tables ===
Expand Down
16 changes: 4 additions & 12 deletions src/backends/lod114d/game/Menu.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,6 @@ namespace d2bs::game {

namespace {

// One reference: a CONTROL_BUTTON dwState value for "selectable" in the
// difficulty buttons. 0x0D matches reference Profile.cpp:174-188 (the in-game
// "enabled" state of normal/nightmare/hell on the SP difficulty screen).
constexpr uint32_t DIFFICULTY_BUTTON_ENABLED = 0x0D;

// D2 string-table (.tbl) indices passed to Control::Find as its localeId filter,
// mirroring the reference OOG_GetLocation's per-control labels: the id is
// resolved to its locale string and matched against the control's text (Button
Expand Down Expand Up @@ -261,7 +256,7 @@ LoginResult Login(const config::ProfileData& profile) {
auto hell = Control::Find(ControlType::Button, 264, 383, 272, 35);

auto clickIfEnabled = [](const std::optional<Control>& c) {
if (!c || c->State() != DIFFICULTY_BUTTON_ENABLED) {
if (!c || c->State() != ControlState::DifficultyEnabled) {
return false;
}
c->Click();
Expand Down Expand Up @@ -534,7 +529,7 @@ bool CreateGame(const std::string& name, const std::string& password, Difficulty
};
const auto attempt = [](uint32_t x, uint32_t y) -> bool {
auto c = Control::Find(ControlType::Button, x, y, 272, 35);
if (!c || c->State() != DIFFICULTY_BUTTON_ENABLED) {
if (!c || c->State() != ControlState::DifficultyEnabled) {
return false;
}
c->Click();
Expand Down Expand Up @@ -572,9 +567,6 @@ bool CreateGame(const std::string& name, const std::string& password, Difficulty
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));

// Bnet difficulty radio buttons: distinct dwState value (0x04 means
// "unavailable"; anything else is selectable).
constexpr uint32_t BNET_DIFF_UNAVAILABLE = 0x04;
const auto pickBnetButton = [](Difficulty d) -> std::optional<Position> {
switch (d) {
case Difficulty::Normal:
Expand All @@ -591,7 +583,7 @@ bool CreateGame(const std::string& name, const std::string& password, Difficulty
const bool diffOk = GameThread::Execute([&]() -> bool {
const auto tryBnet = [](uint32_t x, uint32_t y) -> bool {
auto c = Control::Find(ControlType::Button, x, y, 16, 16);
if (!c || c->State() == BNET_DIFF_UNAVAILABLE) {
if (!c || c->State() == ControlState::Disabled) {
return false;
}
c->Click();
Expand Down Expand Up @@ -825,7 +817,7 @@ OutOfGameLocation GetOutOfGameLocation() {
// is picked. New-account's OK is visible (5), so its verify-password
// textbox is checked after this miss.
if (auto okBtn = Control::Find(ControlType::Button, 627, 572, 128, 35, std::to_underlying(StringId::Ok));
okBtn && okBtn->State() == 0) {
okBtn && okBtn->State() == ControlState::Hidden) {
return OutOfGameLocation::CharacterCreate;
}
if (findControl(ControlType::TextBox, 321, 448, 300, 32, StringId::VerifyPassword)) {
Expand Down
16 changes: 14 additions & 2 deletions src/backends/lod114d/game/Party.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,20 @@
#include <D2Roster.h> // D2RosterUnitStrc
#pragma clang diagnostic pop

#include <utility>

namespace d2bs::game {

static_assert(std::to_underlying(PartyState::Invite) == ROSTERCONTROLFLAG_INVITE);
static_assert(std::to_underlying(PartyState::InParty) == ROSTERCONTROLFLAG_INPARTY);
static_assert(std::to_underlying(PartyState::Accept) == ROSTERCONTROLFLAG_ACCEPT);
static_assert(std::to_underlying(PartyState::Leave) == ROSTERCONTROLFLAG_LEAVE);
static_assert(std::to_underlying(PartyState::Cancel) == ROSTERCONTROLFLAG_CANCEL);
static_assert(std::to_underlying(RosterFlag::Loot) == ROSTERINFOFLAG_LOOT);
static_assert(std::to_underlying(RosterFlag::Ignore) == ROSTERINFOFLAG_IGNORE);
static_assert(std::to_underlying(RosterFlag::Squelch) == ROSTERINFOFLAG_SQUELCH);
static_assert(std::to_underlying(RosterFlag::Hostile) == ROSTERINFOFLAG_HOSTILE);

namespace {

inline D2RosterUnitStrc* AsRoster(void* p) noexcept {
Expand Down Expand Up @@ -74,9 +86,9 @@ uint32_t Party::Life() const {
return p ? p->dwPartyLife : 0;
}

uint32_t Party::PartyFlag() const {
PartyState Party::PartyState() const {
const auto p = Resolve<D2RosterUnitStrc>();
return p ? p->dwPartyFlags : 0;
return p ? static_cast<game::PartyState>(p->dwPartyFlags) : PartyState::Invite;
}

uint16_t Party::PartyId() const {
Expand Down
Loading
Loading