Skip to content

feat(lod114d): PlugY multi-page stash through the StashTab contract - #19

Merged
ResurrectedTrader merged 12 commits into
mainfrom
plugy-pages
Sep 13, 2026
Merged

ResurrectedTrader merged 12 commits into
mainfrom
plugy-pages

Conversation

@ResurrectedTrader

@ResurrectedTrader ResurrectedTrader commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Builds on #18: the 1.14d backend now implements the StashTab contract over PlugY's multi-page stash when PlugY is present, and keeps the vanilla single-tab path otherwise. No contract, JS or character-state changes; scripts get more tabs from the same API.

What PlugY does

Only the shown page's items live in the player's inventory. The others are parked on PlugY's page lists (a PYPlayerData tail appended to D2PlayerDataStrc, two doubly linked Stash lists), removed from the inventory with the game's own routine, so they read as stored-mode items with no parent inventory and a zeroed node byte. Page switches are server round trips over PlugY's overloaded C->S 0x3A packet; the server pushes the result with its custom S->C 0x9D. docs/plugy_stash.md has the full write-up.

Backend (src/backends/lod114d)

  • game/PlugY.h / game/PlugY.cpp (new) and imports/extras/PlugY.h (new, static_asserted struct mirrors): everything PlugY-specific, in its own files so it can be removed wholesale (recipe in the doc).
  • Detection: GetModuleHandle("PlugY.dll") -> VERSIONINFO allowlist (12.00, 14.00, 14.01, 14.02, 14.03; the 1.14d-capable releases, verified identical for every struct / patch site / command this code reads) -> the InitPlayerData alloc call must target PlugY.dll's image (ActiveMultiPageStash=1) -> the alloc size immediate must equal sizeof(D2PlayerDataStrc). A missing module or a not-yet-applied patch is retried until a player unit exists, then cached as a final negative, so a vanilla install pays one atomic load per stash query. Adds the plugy feature tag for analytics.
  • Init without PlugY.exe: plugy::InstallInitHook() from Bridge::Init redirects Game.exe's startup call [LoadLibraryA] (RVA 0x621C) to run PlugY's exported Init once Fog's pool exists, the same point PlugY.exe patches. No-op without the module or when PlugY.exe already patched the site.
  • game/Stash.cpp: delegates to plugy:: while plugy::IsActive() && plugy::HasStashTabs() holds. Tabs enumerate both page lists; Name is the page name; GetItems walks the parked list or the inventory for the shown page; shared tab 0 reports PlugY's shared gold pool and its DepositGold / WithdrawGold send the put/take commands (all-or-nothing, amount ignored, fire and forget).
  • Clicking a page that is not shown: plugy::WithActivePage runs on the script thread: release read locks, take the operation mutex, plan and send the relative page moves, poll the mirror until the target is shown, click, wait for the server's item-action ack keyed on the cursor item id change (a page revert sent before the ack desyncs client and server), send the reverse plan, wait again. ClickItem hands parked items to plugy::ClickParkedItem.
  • Unit::ItemLocation() reports a parked item as Stash.
  • hooks/Intercepts: a single-slot atomic IncomingPacketObserver called at the top of the incoming-packet intercept, used for the ack wait.
  • PlugY's 1.14d patch sites were compared against d2bs's hook sites once (101 vs 28 sites, zero overlaps against 14.03); the result and the nearest miss are recorded in the doc.

Docs

docs/plugy_stash.md (new) covers the PlugY mechanics, detection, init path, overlap check, click sequence, gold and the removal recipe; docs/stash_tabs.md and CLAUDE.md point at it.

Verification

This code was built, linted, tested and exercised in-game against PlugY 14.03 (single player) before being split from #18: page enumeration, names, parked-item reads, unit.stashTab, clicking items into and out of inactive pages with the ack wait, shared gold moves. On this branch: clang-format clean, clang-tidy 112/112 passed. Not re-run in-game after the rebase onto the merged contract.

🤖 Generated with Claude Code

ResurrectedTrader and others added 3 commits September 13, 2026 12:12
…ontract

When a supported PlugY build (12.00, 14.00-14.03) has its multi-page stash
installed, game/Stash.cpp delegates every StashTab member to game/PlugY.cpp:
tabs enumerate PlugY's personal and shared page lists, GetItems reads items
parked on pages that are not shown, Name is the user-given page name, shared
tab 0 carries PlugY's shared gold pool, and Click / ClickItem swap the target
page in over PlugY's 0x3A command channel, click, wait for the server's item
action acknowledgement, and swap back before returning. Without PlugY the
vanilla single-tab path is unchanged.

Detection is module handle -> version allowlist -> the InitPlayerData alloc
call redirected into PlugY.dll -> alloc size == sizeof(D2PlayerDataStrc), so
the PYPlayerData tail is only ever read when PlugY actually appended it.
Bridge::Init installs a hook on Game.exe's startup LoadLibraryA call that
runs PlugY's exported Init when PlugY.dll was injected without PlugY.exe.
Unit::ItemLocation reports a parked item (stored mode, no parent inventory)
as Stash. Intercepts gains a single-slot incoming-packet observer for the
ack wait. scripts/plugy_overlap_audit.py checks PlugY's 1.14d patch sites
against d2bs's hook sites (zero overlaps against 14.03).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The comparison it ran is recorded in docs/plugy_stash.md; the script was a one-off.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…asStashTabs, page walks as struct members

IsActive() now caches a missing PlugY.dll as a final negative once a player unit exists (PlugY loads and runs Init at Game.exe startup, long before that), so a vanilla install pays one atomic load per stash query instead of a mutex and a loader lookup on every call.

HasPages() becomes HasStashTabs() and no longer hides the IsActive() check; Stash.cpp spells out plugy::IsActive() && plugy::HasStashTabs() at each delegation, and IsParkedItem, the one entry reached from the generic item paths, checks both itself.

The page-list walks (Head / ForEachPage / FindPage / PageCount / ActivePage / IsActivePage) and the page name conversion move onto the PYPlayerData / Stash mirrors in imports/extras/PlugY.h; they add no data, and the size static_asserts still hold.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread src/backends/lod114d/game/PlugY.cpp Outdated
}

// Page names are typed into PlugY's in-game text box and stored in the ANSI code page.
std::string PageName(const Stash& page) {

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 these just be member methods on the struct, or does that change the struct size?

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.

Moved onto PYPlayerData / Stash in e1551a0 and d4e59d9. Member functions add no data and no vtable, so the layout is unchanged; the sizeof static_asserts (0x18 / 0x1C) still hold.

Comment thread src/backends/lod114d/game/PlugY.cpp Outdated
return count;
}

std::optional<PageRef> ActivePage(const PYPlayerData& ext) {

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.

Again, could be member functions?

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.

Moved onto PYPlayerData / Stash in e1551a0 and d4e59d9. Member functions add no data and no vtable, so the layout is unchanged; the sizeof static_asserts (0x18 / 0x1C) still hold.

Comment thread src/backends/lod114d/game/PlugY.cpp Outdated
return ForEachPage(ext, kind, [index](const Stash&, uint32_t i) { return i == index; });
}

uint32_t PageCount(const PYPlayerData& ext, StashTabKind kind) {

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.

Again, could be member functions?

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.

Moved onto PYPlayerData / Stash in e1551a0 and d4e59d9. Member functions add no data and no vtable, so the layout is unchanged; the sizeof static_asserts (0x18 / 0x1C) still hold.

Comment thread src/backends/lod114d/game/PlugY.cpp Outdated
return nullptr;
}

const Stash* FindPage(const PYPlayerData& ext, StashTabKind kind, uint32_t index) {

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.

Again, could be member functions?

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.

Moved onto PYPlayerData / Stash in e1551a0 and d4e59d9. Member functions add no data and no vtable, so the layout is unchanged; the sizeof static_asserts (0x18 / 0x1C) still hold.

Comment thread src/backends/lod114d/game/PlugY.cpp Outdated
// Walks `kind`'s pages in order, calling fn(page, index) until it returns true;
// returns the page it stopped on, or nullptr.
template <typename Fn>
const Stash* ForEachPage(const PYPlayerData& ext, StashTabKind kind, const Fn& fn) {

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.

Again, could be member functions?

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.

Moved onto PYPlayerData / Stash in e1551a0 and d4e59d9. Member functions add no data and no vtable, so the layout is unchanged; the sizeof static_asserts (0x18 / 0x1C) still hold.

Comment thread src/backends/lod114d/game/PlugY.cpp Outdated
if (inProgress) {
return ClickResult::StashTabUnavailable;
}
const ScopedFlag scope(inProgress);

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.

Don't we have something generic in utils package for this?

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.

Yes, utils/DeferGuard.h. Switched to it in d4e59d9 and dropped ScopedFlag.

Comment thread src/backends/lod114d/game/Unit.cpp Outdated
const auto location = static_cast<game::ItemLocation>(u->pItemData->pExtraData.nNodePos);
// Removing an item from an inventory zeroes its node byte, so an item parked on
// an inactive stash page reads Ground here; report it where it logically is.
if (location == ItemLocation::Ground && plugy::IsParkedItem(u)) {

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.

add plugy:IsActive & HasStashTabs etc?

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 d4e59d9.

if (index_ != 0 || !*this) {
return 0;
}
if (kind_ == StashTabKind::Shared) {

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.

Also want isActive HasStashTabs check

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 d4e59d9.

Comment thread src/backends/lod114d/game/PlugY.cpp Outdated
const PYPlayerData* Pages() {
const auto* ext = Extension();
return ext != nullptr && ext->currentStash != nullptr ? ext : nullptr;
}

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.

This returns the same thing as Extension(), so I'd rather we used Extension() everywhere and then check if currentStash != null everywhere.

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 d4e59d9: Pages() is gone, every site calls Extension() and checks currentStash != nullptr itself.

Comment thread src/backends/lod114d/game/PlugY.cpp Outdated
bool IsStashItem(const D2UnitStrc* item) {
return item->pItemData != nullptr &&
static_cast<ItemLocation>(item->pItemData->pExtraData.nNodePos) == ItemLocation::Stash;
}

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.

inline

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.

Inlined in bf23d3f: IsStashItem, FirstItem and NextItem are folded into a single out-of-line PYPlayerData::ForEachItem.

ResurrectedTrader and others added 2 commits September 13, 2026 13:02
Item walks (FirstItem / NextItem / IsStashItem / ForEachItem) and the page-switch planner become PYPlayerData members, the 0x3A command bytes move next to the structs, and Version gets Read / ToString. Pages() is folded into Extension() with the currentStash check spelled out at each use. The generic item paths (Unit::ItemLocation, ClickItem) and StashTab::Gold check IsActive() && HasStashTabs() before calling into plugy::, so IsParkedItem is only the signature test. The re-entry flag uses DeferGuard; the cursor-id read is a local lambda.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…hItem

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

const Stash* Head(game::StashTabKind kind) const {
return kind == game::StashTabKind::Shared ? sharedStash : selfStash;
}

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 be inlined.

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.

Inlined in 9ef5dc5 (its only use was ForEachPage).

return kind == game::StashTabKind::Shared ? sharedStash : selfStash;
}

bool IsActivePage(const Stash& page) const { return &page == currentStash; }

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 also be inlined?

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.

Inlined in 9ef5dc5 at both uses.

if (name == nullptr || name[0] == '\0') {
return {};
}
return utils::ToStr(utils::ToWStr(name, CP_ACP));

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.

Given this is ansi, can't we just to std::string(name) ?

Comment thread src/backends/lod114d/game/PlugY.cpp Outdated

std::string VersionLabel(HMODULE module) {
const auto version = Version::Read(module);
return version ? version->ToString() : std::string("(unknown version)");

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.

Surely the std::string wrapper is redundant? we could just return "blah" and that would set the type implicitly?

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.

Dropped in 9ef5dc5; the ternary converts the literal to std::string on its own.

return Version{.major = HIWORD(info->dwFileVersionMS),
.minor = LOWORD(info->dwFileVersionMS),
.build = HIWORD(info->dwFileVersionLS)};
}

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.

This whole thing with versions could probably live in utils as seems generic enough for any module?

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.

Moved in 9ef5dc5: utils::ModuleVersion / utils::GetModuleVersion(HMODULE) read any module's FILEVERSION (all four fields); PlugY.cpp keeps only its 14.03-style label and the allowlist.

ResurrectedTrader and others added 7 commits September 13, 2026 13:24
…e-line page helpers

utils::GetModuleVersion / ModuleVersion replace PlugY.cpp's private Version reader (any module's FILEVERSION, not just PlugY's); PlugY keeps only its 14.03-style label. PYPlayerData::Head and IsActivePage are inlined at their uses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…shared code, mirror guards on the accessors

game::PollUntil(timeout, interval, ready) lives in contract game/GameLock.h next to the lock releaser it uses; PlugY's page-switch and ack waits and Menu.cpp's char-create wait use it. utils gains IsInsideModule and ReadValue<T>. PYPlayerData::HasStashTabs / HasSharedStash / SharedGold carry the populated-mirror checks, so every PlugY.cpp read is just ext != nullptr ? ext->X : empty.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PlugY resolves its first-page command (0x1F) through the server's showSharedStash flag, which only the kind-select commands set, so a backward move within the shared stash could land on the personal stash's first page and the revert after a click timed out ("stash page 1:0 could not be restored"). The kind-select commands land on the kind's first page whether or not the kind changes, so the planner uses them as the jump; with the shared stash disabled (where PlugY ignores them) it falls back to previous singles. A switch that does not complete now logs the plan size and the page the mirror shows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…old gold moves

Review follow-ups on the multi-page stash:
- JSUnit 'location' getter takes Bridge::Lock, so IsParkedItem's inventory-pointer
  chase can't read a unit PlugY is relinking on the game thread mid-switch.
- The click ack waits for the specific cursor item id(s) via atomics instead of
  accumulating every item packet into a 64-capped vector that could drop the
  awaited id under a packet burst; also removes the ack mutex.
- StashTab::DepositGold / WithdrawGold fold into one MoveStashGold helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sit/WithdrawGold

The contract StashTab exposes a single MoveGold; the backend implements it directly. JS keeps depositGold(amount) / withdrawGold(amount), now thin wrappers that call MoveGold with the mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PlugY's updateItem hook reselects the page of any stash item the server updates, so once a click deposits an item on the target page (drop or swap) PlugY snaps the view back and the revert switch cannot win. The forward switch still warns and returns StashTabUnavailable on failure; a lost revert is now a debug line, since the click already happened and which page is shown is not part of the contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ResurrectedTrader
ResurrectedTrader merged commit 06b26c7 into main Sep 13, 2026
1 check passed
@ResurrectedTrader
ResurrectedTrader deleted the plugy-pages branch September 13, 2026 16:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant