Vendor Price-Markup Hook: Address Library Spike — What Report 26 Got Half Right¶
Document File ID: docs/research/28-vendor-price-hook-address-library-spike.md
TL;DR¶
Report 26 filed the vendor-price-markup mechanism as [RISK —
reverse-engineered hook required], framing the remaining unknown as "an
internal, unmapped engine function" that mod 144874 (Dynamic Prices
Framework, JerryYOJ) allegedly detours into directly, and recommending a
SigMaker/Address-Library spike to find it before scheduling any build
work. That framing was wrong, and the spike this doc ran found something
better: DPF's actual open-source implementation (github.com/JerryYOJ/
DynamicPrices-SKSE) shows it never touches an unmapped internal function
at all. Read directly, DynamicPrices.cpp does two things, both fully
inside CommonLibSSE-NG's already-documented, Address-Library-backed
surface: (1) a vtable-slot swap on RE::IMenu::PostCreate() — vtable
index 0x2, a real documented virtual in RE/I/IMenu.h:68, reached via
RE::VTABLE_BarterMenu[0] (REL::VariantID(267991, 214926, 0x172ebf0),
already sitting unused in this project's own local CommonLibSSE-NG
checkout at include/RE/Offsets_VTABLE.h:3998) and REL::Relocation::
write_vfunc() (a real public method, REL/Relocation.h:392); and (2) a
Scaleform/GFx UI-layer function swap — it replaces the barter menu's
ActionScript "UpdateItemCardInfo" callback via RE::GFxFunctionHandler
+ GFxMovieView::CreateFunction() (both real, header-confirmed
CommonLibSSE-NG types), intercepting the per-item-row price value after
Flash/ActionScript has already asked native code to compute it, multiplying
it, then forwarding to the saved original callback so display/flow stays
intact. No internal, unmapped native price-calculation function is
located, named, or hooked anywhere in this implementation — the "hooks
the price-calc system directly" language report 16/18 inherited from
mod-description summaries was a simplification of a UI-callback-swap
technique, not a literal native-code detour. Recommendation: this is
buildable now, using only APIs this project's local CommonLibSSE-NG
checkout already exposes, at a risk tier closer to DeathEventSink's
event-sink pattern than to a from-scratch SigMaker hunt — with one real,
honestly-flagged caveat this pass could not verify without a live game
session: whether the AS3-side "value" this technique multiplies is the
same value the engine actually charges/pays on Buy/Sell, or only the
displayed tooltip figure (see Caveats).
Findings¶
[F1] [VERIFIED] The real DPF implementation is public, found via GitHub
code search on the exact callback signature this task's own prompt (and
report 26) already quoted verbatim. Searching GitHub code search for
InventoryEntryData GFxValue is_buying (a fragment unique enough to be
load-bearing) returned 7 hits total, two of which were this project's own
already-filed research markdown, and among the rest:
JerryYOJ/DynamicPrices-SKSE at src/scaleform/DynamicPrices/
DynamicPrices.cpp and .h. The author name (JerryYOJ), the exact
callback signature, the Data/SKSE/DynamicPrices/*.json config-folder
convention ("create a folder under your mod's SKSE folder named
DynamicPrices with a JSON file to register the callback" — independently
confirmed by a separate web search of the mod's own description text),
and the "NativeCallbacks" JSON key all match report 16/18's and this
task's description of mod 144874 exactly. This project could not load
nexusmods.com/skyrimspecialedition/mods/144874 directly (HTTP 403,
Cloudflare — the exact expected block this task's own brief anticipated)
to get a byte-for-byte Nexus-page confirmation that this GitHub repo is
literally the shipped DLL's source, so treat the identification as
strong circumstantial match, not a notarized fact.
[F2] [VERIFIED] The "hook" is a vtable-slot swap on a real, documented
RE::IMenu virtual function — not a raw code-cave/detour into unmapped
engine code. The relevant lines from DynamicPrices::Install():
void DynamicPrices::Install() {
REL::Relocation<std::uintptr_t> Vtbl{ RE::VTABLE_BarterMenu[0] };
_PostCreate = Vtbl.write_vfunc(0x2, &PostCreate);
}
Every symbol here is already real and present in this project's own local CommonLibSSE-NG checkout, independently confirmed by direct grep, not taken on the source repo's word:
RE::VTABLE_BarterMenu—include/RE/Offsets_VTABLE.h:3998:constexpr std::array<REL::VariantID, 1> VTABLE_BarterMenu{ REL::VariantID(267991, 214926, 0x172ebf0) };— a real, Address-Library-ID-backed (267991SE /214926AE) vtable relocation, the exact sameRELOCATION_ID/VariantIDidiom report 26's F1 already vetted forBarterMenu::GetTargetRefHandle()'sRefHandle. This entry already exists in the checkout — it was simply unused by any code in this project (or, per report 26's F6 grep, anywhere in ChronicleBridge) until this pass looked for it.RE::IMenu::PostCreate()—include/RE/I/IMenu.h:68:virtual void PostCreate(); // 02 - { return; }— a real, named, documented virtual function at vtable index0x2, inherited (not overridden) byBarterMenu, confirmed by cross-referencingRE/B/BarterMenu.h's own override list (~BarterMenuat 00,Acceptat 01,ProcessMessageat 04,PostDisplayat 06 —02/03/05are conspicuously absent, i.e. inherited straight fromIMenu, exactly wherePostCreatelives).REL::Relocation<T>::write_vfunc(idx, newFunc)—include/REL/Relocation.h:392— a real public member function on the sameREL::Relocationtemplate this project's every prior slice already uses for reads; this is its documented write-a-vtable-slot overload.
This means "hook BarterMenu at the moment it's constructed" requires zero fresh reverse-engineering — it is a vtable pointer swap on an address this checkout already carries an Address Library ID for, onto a function CommonLibSSE-NG already names.
[F3] [VERIFIED] The actual price manipulation happens one layer up,
in Scaleform/GFx (Flash UI), not in native price-calculation code at
all — DPF replaces the barter menu's ActionScript UpdateItemCardInfo
callback, not any native price function. From PostCreate:
RE::GFxValue oldf;
root.GetMember("UpdateItemCardInfo", &oldf);
RE::GFxValue newf;
auto&& impl = RE::make_gptr<DynamicPrices>(std::move(oldf), std::move(hashMap), thiz);
barter->uiMovie->CreateFunction(&newf, impl.get());
root.SetMember("UpdateItemCardInfo", newf);
and then, in the replaced callback's Call():
RE::GFxValue value(RE::GFxValue::ValueType::kNumber);
a_updateObj.GetMember("value", &value);
value.SetNumber(value.GetNumber() * mult);
a_updateObj.SetMember("value", value);
...
oldFunc.Invoke("call", a_params.retVal, a_params.argsWithThisRef, a_params.argCount + 1);
RE::GFxFunctionHandler (base class DPF's DynamicPrices type derives
from) and GFxValue/GetMember/SetMember/Invoke are all real,
header-confirmed CommonLibSSE-NG types
(include/RE/G/GFxFunctionHandler.h) — this is the same "replace an
ActionScript-callable native function object" technique used throughout
the wider Scaleform-menu-modding ecosystem (SkyUI's own extension
mechanism works this way), not novel to DPF and not requiring any
sig-scan of its own. The vendor's Actor* is obtained the same way
report 26's F1 already found — a RefHandle relocation matching
RE::BarterMenu::GetTargetRefHandle()'s underlying static (DPF's copy:
RELOCATION_ID(519283, 405823); this project's current local checkout
has RELOCATION_ID(519283, 403520) for the same SE ID 519283 — same SE
offset, differing AE offset, most likely reflecting a different
CommonLibSSE-NG snapshot DPF was built against rather than any error;
use this project's own local checkout's number, not DPF's, since
ADR-0008 pins 1.6.1170 and that's the header this project actually
vendors — treat the discrepancy as a version-drift caveat to
double-check against the Address Library DB directly before writing
code, not as evidence either number is wrong).
[F4] [VERIFIED] RE::ItemList::GetSelectedItem() (used to identify
which row/item is being priced) is likewise a real, already-declared
CommonLibSSE-NG member, include/RE/I/ItemList.h:27, so the full chain
from "menu opened" → "which vendor" → "which item row" → "multiply its
price" resolves entirely inside already-documented API surface.
[F5] [CORRECTS REPORT 26] Report 26's central risk claim — "no
RE::-exposed... this is an internal, unmapped engine function" — was
an inference from mod descriptions (Nexus/community summaries saying a
plugin "hooks the price-calc system directly"), not from reading any
actual implementation; this pass is the first to read one. The
description-level framing ("hooks the internal price-calculation
routine") is not technically false at a narrative level — the price
figure IS intercepted before it reaches the player — but it conflates
"before the player sees it" with "inside native price-calculation code,"
which turned out not to match the real technique at all. This is worth
naming plainly: report 26 did the right thing by flagging the gap
honestly and scoping a follow-up spike rather than guessing at an
address; this pass's finding is that the follow-up spike's premise
(there must be an unmapped native function to find) doesn't hold up once
real source is read.
Recommendation¶
Build this now, using only report 28's confirmed API surface — do not schedule a SigMaker/Address-Library address hunt, because there is no unmapped function left to hunt for. Concretely, ChronicleBridge's C++ side would need:
- A vtable-swap install step (once, at plugin load), following
DynamicPrices::Install()'s exact shape: - Inside the overridden
PostCreate, resolve the vendorActor*via the sameRefHandlerelocation report 26's F1 already trusts (or, more simply, just call the already-publicRE::BarterMenu:: GetTargetRefHandle()wrapper directly instead of re-declaring the raw relocation — no reason to duplicate what CommonLibSSE-NG already exposes cleanly), thenIdentityMap-resolve it to a Chroniclenpc_idexactly asHydrationPoller::ResolveLiveNpcalready does. - Swap the barter menu's
"UpdateItemCardInfo"ActionScript member viaRE::GFxFunctionHandler+uiMovie->CreateFunction(), following DPF'sCall()shape, but replacing DPF's "ask other DLLs via GetProcAddress" indirection with a direct in-process call to ChronicleBridge's own already-polledmarkup_multipliercache for thatnpc_id(the same GET-polled cache shapeHydrationPoller/AvoidancePolleralready use per report 26's F5) — this is a simplification of DPF's design (no need for the cross-DLL JSON callback-registration indirection at all, since ChronicleBridge would be both the hook owner and the multiplier's only consumer). - Use
SKSE::GetTrampoline()only if a raw inline/codecave hook is ever needed elsewhere — it is not needed for this slice;write_vfuncis a simpler, self-contained vtable-pointer write that doesn't require the trampoline allocator at all, which is itself a lower-risk profile than report 26 assumed.
Revised risk classification: downgrade from report 26's [RISK —
reverse-engineered hook required] to [BUILD-ON — vtable-swap +
Scaleform-callback-swap, both fully inside documented CommonLibSSE-NG
surface]. This is a new pattern for ChronicleBridge (its first
vtable/UI-layer hook, versus every prior slice's plain field write or
BSTEventSink), so it is fairly scoped as its own implementation step
rather than folded silently into hydration/avoidance-tier work — but it
is not the same order of unstarted, open-ended R&D report 26 described.
It is buildable in the next implementation pass, pending the one
verification caveat below.
Caveats¶
- Not verified in this pass, and the one thing that would need a live
game session to settle: does the
"value"field DPF's replacedUpdateItemCardInfomultiplies actually drive the gold amount exchanged onBuy/Sell, or only the displayed tooltip/list figure (with the real transaction gold computed by a separate, still-internal native call)? DPF's real-world reception (report 16/18: it backs The Gilded Road, the best-received economy mod this project's survey found) is strong circumstantial evidence it does affect the real transaction, not just cosmetics — mods with only-cosmetic price display don't earn "regional economy overhaul" framing — but this pass read source, it did not decompileBarterMenu.swf's ActionScript or run the game to confirm the data flow end-to-end. This is the one item worth a short live-game smoke test before committing an implementation timeline, not a reason to redo the address search. JerryYOJ/DynamicPrices-SKSEcarries no LICENSE file (GitHub reportslicense.key: "other"/spdx_id: "NOASSERTION", confirmed by directgh apiquery) — treat this as a real, useful reference for the pattern (as this doc does), not as code to vendor or copy verbatim into ChronicleBridge without contacting the author.- The
RefHandlerelocation ID discrepancy noted in F3 (405823in DPF's source vs.403520in this project's local checkout, same SE ID519283) should be spot-checked against the Address Library's own database directly (or simply avoided by calling the existingRE::BarterMenu::GetTargetRefHandle()wrapper instead of hand-rolling the relocation a second time) before writing code — flagged, not resolved, in this pass. - This pass did not attempt to build, compile, or run anything against a live game, per this task's own scope — it is a source-reading and header-cross-referencing pass, one step more concrete than report 26's documentation-only pass (which had no implementation source to read yet) but still short of an in-game verification.
- The Nexus mod page (144874) itself remained unreachable (HTTP 403,
Cloudflare) in this pass, exactly as anticipated by this task's brief;
the identification of
JerryYOJ/DynamicPrices-SKSEas that mod's source rests on strong circumstantial matches (author handle, exact callback signature, exact config-folder convention), not a page-level confirmation.