feat(lod114d): PlugY multi-page stash through the StashTab contract - #19
Conversation
…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>
| } | ||
|
|
||
| // Page names are typed into PlugY's in-game text box and stored in the ANSI code page. | ||
| std::string PageName(const Stash& page) { |
There was a problem hiding this comment.
Could these just be member methods on the struct, or does that change the struct size?
| return count; | ||
| } | ||
|
|
||
| std::optional<PageRef> ActivePage(const PYPlayerData& ext) { |
There was a problem hiding this comment.
Again, could be member functions?
| return ForEachPage(ext, kind, [index](const Stash&, uint32_t i) { return i == index; }); | ||
| } | ||
|
|
||
| uint32_t PageCount(const PYPlayerData& ext, StashTabKind kind) { |
There was a problem hiding this comment.
Again, could be member functions?
| return nullptr; | ||
| } | ||
|
|
||
| const Stash* FindPage(const PYPlayerData& ext, StashTabKind kind, uint32_t index) { |
There was a problem hiding this comment.
Again, could be member functions?
| // 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) { |
There was a problem hiding this comment.
Again, could be member functions?
| if (inProgress) { | ||
| return ClickResult::StashTabUnavailable; | ||
| } | ||
| const ScopedFlag scope(inProgress); |
There was a problem hiding this comment.
Don't we have something generic in utils package for this?
There was a problem hiding this comment.
Yes, utils/DeferGuard.h. Switched to it in d4e59d9 and dropped ScopedFlag.
| 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)) { |
There was a problem hiding this comment.
add plugy:IsActive & HasStashTabs etc?
| if (index_ != 0 || !*this) { | ||
| return 0; | ||
| } | ||
| if (kind_ == StashTabKind::Shared) { |
There was a problem hiding this comment.
Also want isActive HasStashTabs check
| const PYPlayerData* Pages() { | ||
| const auto* ext = Extension(); | ||
| return ext != nullptr && ext->currentStash != nullptr ? ext : nullptr; | ||
| } |
There was a problem hiding this comment.
This returns the same thing as Extension(), so I'd rather we used Extension() everywhere and then check if currentStash != null everywhere.
There was a problem hiding this comment.
Done in d4e59d9: Pages() is gone, every site calls Extension() and checks currentStash != nullptr itself.
| bool IsStashItem(const D2UnitStrc* item) { | ||
| return item->pItemData != nullptr && | ||
| static_cast<ItemLocation>(item->pItemData->pExtraData.nNodePos) == ItemLocation::Stash; | ||
| } |
There was a problem hiding this comment.
Inlined in bf23d3f: IsStashItem, FirstItem and NextItem are folded into a single out-of-line PYPlayerData::ForEachItem.
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; | ||
| } |
There was a problem hiding this comment.
could be inlined.
There was a problem hiding this comment.
Inlined in 9ef5dc5 (its only use was ForEachPage).
| return kind == game::StashTabKind::Shared ? sharedStash : selfStash; | ||
| } | ||
|
|
||
| bool IsActivePage(const Stash& page) const { return &page == currentStash; } |
There was a problem hiding this comment.
Could also be inlined?
| if (name == nullptr || name[0] == '\0') { | ||
| return {}; | ||
| } | ||
| return utils::ToStr(utils::ToWStr(name, CP_ACP)); |
There was a problem hiding this comment.
Given this is ansi, can't we just to std::string(name) ?
|
|
||
| std::string VersionLabel(HMODULE module) { | ||
| const auto version = Version::Read(module); | ||
| return version ? version->ToString() : std::string("(unknown version)"); |
There was a problem hiding this comment.
Surely the std::string wrapper is redundant? we could just return "blah" and that would set the type implicitly?
There was a problem hiding this comment.
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)}; | ||
| } |
There was a problem hiding this comment.
This whole thing with versions could probably live in utils as seems generic enough for any module?
There was a problem hiding this comment.
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.
…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>
Builds on #18: the 1.14d backend now implements the
StashTabcontract 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
PYPlayerDatatail appended toD2PlayerDataStrc, two doubly linkedStashlists), 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.mdhas the full write-up.Backend (
src/backends/lod114d)game/PlugY.h/game/PlugY.cpp(new) andimports/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).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) -> theInitPlayerDataalloccallmust target PlugY.dll's image (ActiveMultiPageStash=1) -> the alloc size immediate must equalsizeof(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 theplugyfeature tag for analytics.plugy::InstallInitHook()fromBridge::Initredirects Game.exe's startupcall [LoadLibraryA](RVA 0x621C) to run PlugY's exportedInitonce 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 toplugy::whileplugy::IsActive() && plugy::HasStashTabs()holds. Tabs enumerate both page lists;Nameis the page name;GetItemswalks the parked list or the inventory for the shown page; shared tab 0 reports PlugY's shared gold pool and itsDepositGold/WithdrawGoldsend the put/take commands (all-or-nothing,amountignored, fire and forget).plugy::WithActivePageruns 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.ClickItemhands parked items toplugy::ClickParkedItem.Unit::ItemLocation()reports a parked item asStash.hooks/Intercepts: a single-slot atomicIncomingPacketObservercalled at the top of the incoming-packet intercept, used for the ack wait.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.mdand 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