Skip to content

Loot filter: PoE-style ground item visuals (label background, frame, size, beams, automap shapes) - #99

Open
errolgr wants to merge 15 commits into
Project-Diablo-2:mainfrom
errolgr:feature/filter-visuals
Open

errolgr wants to merge 15 commits into
Project-Diablo-2:mainfrom
errolgr:feature/filter-visuals

Conversation

@errolgr

@errolgr errolgr commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds Path of Exile style ground item visuals to the loot filter: a filter rule can now give an item's ground label a background colour, opacity, frame and size, put a light beam on it (permanent, or a short flash when it drops), and choose the shape of its automap marker. Everything is opt-in through new %KEYWORDS%; filters that don't use them look exactly like today.

hero

D2GL, HD text on. Ber: red text on a white box with a red frame and a red beam; Jah: white on red; Lo/Sur: white on orange-red / black; mid runes: coloured frames on see-through boxes; Shako (unique) and Sigon's Visor (set) keep their name colour with a matching frame; the key and potion are today's plain labels.

Problem

A filter can change a label's text and colour and mark items on the automap, but every ground label is the same small text on the same black, half-transparent box. A Ber and a Chipped Ruby look alike on a busy screen until you read them. Players coming from PoE (NeverSink / FilterBlade filters) expect labels that stand out by tier, and PD2 filter authors already borrow PoE conventions (the PoE alert sounds 4714-4729 are in PD2's sounds.txt and used by filters).

Changes

New filter keywords

XX is a 2-digit hex palette index, the same as %MAP-XX%.

Keyword Effect Without it
%BG-XX% label background colour black box (today)
%OPACITY-25|50|75|100% background opacity 50 (today)
%FRAME-XX% 1 px border around the label none
%SIZE-S|M|L% label font: 1 (today), 2, 3 S
%BEAM-XX% light beam above the item while it is on the ground none
%FLASH-XX% beam for ~2 s when the item drops (items already lying there never flash) none
%ICON-SQUARE|CIRCLE|DIAMOND|STAR|TRIANGLE|CROSS% shape of the item's automap marker (%MAP%/%BORDER%/%DOT%/%PX% keep their size and colour) square (today)
%HOVERBG-XX% background colour while the label is hovered the normal %BG%
%HOVEROPACITY-25|50|75|100% background opacity while hovered the normal %OPACITY%
%HOVERFRAME-XX% frame colour while hovered the normal %FRAME%
%HOVERTEXT-<colour>% whole label text in one colour while hovered (BH's colour words: WHITE, RED, GOLD, ...) the label's own colours

They work like the existing keywords: stripped from the label text, case-insensitive, combine with %CONTINUE% (a later matching rule overrides only the fields it sets), and can live in an Alias, so a whole colour theme is a few alias lines:

Alias[T1BOX]: %BG-0A%%OPACITY-100%%FRAME-20%%BEAM-0A%
ItemDisplay[RUNE=30]: %WHITE%Ber Rune%T1BOX%

A malformed value (%BG-ZZ%, %OPACITY-30%, %ICON-HEXAGON%) is shown as typed and sets nothing, like any unknown keyword today. A rule that only sets visual keywords does not become a notification rule (no drop message or sound).

Hover. A hovered styled label (Alt held, or BH's Always Show Items) keeps its style. If the matching rules set no %HOVER...% keyword it gets lighter: a 25 % white layer over the box, frame unchanged, so every styled label has a hover cue without any filter change. The %HOVER...% keywords replace that with an explicit hovered look, and a rule with only hover keywords keeps the stock label until it is hovered. Unstyled labels keep the game's own hover box exactly. Hit-testing and pick-up are unchanged.

hover

Colour themes: only the Alias block differs Sizes S / M / L
themes sizes
Beams + a flash right after a drop ~4 s later: flash gone, beams stay Automap marker shapes
flash gone automap
Inventory open: the world view shifts, beams stay on their runes and stop at the panel
inventory

How it works

Part Files Details
Keywords + style lookup ItemDisplay.h/.cpp New Action fields, parsed in BuildAction. GetGroundStyle(item) resolves an item's style across the rules (with %CONTINUE%), cached like the name/map caches and reset on filter reload / filter level change. Filters without visual keywords skip the lookup entirely (a flag set when the filter loads).
Label styles GroundLabels.cpp/.h (new), Item.cpp PD2's ProjectDiablo.dll replaces D2Client's ground-label renderer (0x58FB0) with its own, but both draw each label through D2Win #10013 (box + text) and measure it with #10177. BH hooks those two entries and treats a call as a ground label only if its text is the name field of a label slot holding an item on the ground and every other argument matches that slot; everything else goes to the original function with the original arguments. No ProjectDiablo.dll offsets are used. The hooks are byte-checked (an existing jmp detour like D2GL's is accepted), and if anything differs the styles stay off with a one-time chat note.
D2GL HD text GroundLabels.cpp With D2GL's hd_text on (PD2's default), D2GL's #10013 ignores the box colour. When D2GL is actually rendering with HD text, a styled label draws its own box (#10014), frame and centred text (#10150); unstyled labels and every other path are unchanged.
Hover GroundLabels.cpp, ItemDisplay.h/.cpp The game marks the hovered label by calling #10013 with its hover-blue colour in opaque mode; for styled labels BH draws the label's own style instead, with either the %HOVER...% fields or the 25 % white layer on top. The text recolour for %HOVERTEXT% works on a copy of the string, so measuring, stacking and hit-testing are untouched.
Beams ItemBeams.cpp/.h (new), MapNotify.cpp, D2Ptrs.h A thin needle of light (13 px at the foot, tapering to 1 px over 190 px) made of 1 px columns with dithered tails, a faint core, rising sparks and a small light pool at the foot. Drawn just before the first ground label each frame, so labels sit on top: the foot sits 4 px above the item's ground point, just under where an unstacked label's box ends, so the label covers the beam's base and the foot glow peeks out below it; the same fixed point whether labels are shown or not, so nothing moves on Alt. At most 16 beams (nearest the player). Flash = the client's fresh-drop flag (ITEM_NEW), fading over 2 s. Positioned with the same transform the engine uses for labels, including the sideways world shift when one side panel is open (D2Client+0x11C418) and, under D2GL with motion_prediction on (PD2's default), D2GL's per-frame prediction offset (d2glGetGlobalXOffset/YOffset, the same exports ProjectDiablo.dll uses for the labels), so beams stay on their item while the player walks, clipped to the visible half when a side panel is open (ScreenCovered), hidden under full-screen menus.
Marker shapes MarkerShapes.cpp/.h (new), MapNotify.cpp Shapes rasterised at the existing marker sizes; squares keep the old drawing code.
Project files BH.vcxproj, .filters, CMakeLists.txt New files added.

Testing

Check Result
Unit tests (93 new): keyword parsing incl. 13 malformed forms and the %HOVER...% keywords, %CONTINUE% overrides, aliases, notification-rule separation, cache reset; label hook trampolines on the real D2Win prologues (incl. D2GL's detour); beam geometry, fade, flash eligibility, 16-beam cap, panel clipping at 800 and 1068 px; marker shape pixels at every size; hover look fallbacks, the default lighter layer and %HOVERTEXT% recolouring of multi-colour text all pass against this exact code (598 with #98's suite, MSVC). They are not in this PR so it doesn't depend on #98; they follow in a separate PR once #98 is merged
MSVC Release|Win32 build green
In game: styled labels (colour, all four opacities, frame, size, 12 labels stacked on one spot) measured from screenshots PASS
In game: hover. Default lighter look on dark, light, framed and see-through boxes (every box pixel lighter, frame unchanged), each %HOVER...% keyword, hover-only rules, an unstyled label keeps the game's hover exactly, call audit with hover args, clicking a hovered styled label still picks the item up; with Alt held and with Always Show Items on PASS
In game: decorated filter lines (several colours, * symbols, padding, e.g. PICK **** [ETH] Archon Plate **** UP) with a style: box and frame wrap the whole line, every colour kept PASS (checked by hand on D2GL HD text)
In game: call audit. Every call to #10013/#10177 recorded with the hooks on and off, in 13 UI states (inventory, hover popup, character, skills, quests, automap, BH stats, chat, NPC menu, trade, store popup, waypoint menu) with a styled filter 0 non-label calls changed, 0 misclassified
In game: 4 popular filters without the new keywords (PD2 default, Kassahi, Wolfie, edge), call audit hooks on vs off identical, 0 restyled
In game: beams, flash (on drop, gone after 3 s, never on revisit), all 10 marker variants pixel-exact, beams clipped under character / inventory / waypoint panels and hidden under the game menu, beams stay on their item (within 2 px of the engine's label) with the inventory or character panel open, beam position identical with Alt up and down, beam foot just below the label box PASS
Frame cost, 100 labelled items + 16 beams +0.2-0.4 ms p95 over stock (game-thread CPU)
Renderers DirectDraw, D2GL with HD text on and off, motion prediction on and off (checked by hand while walking), 1068×600 and 1600×900

Known differences under D2GL HD text: styled boxes have square ends (D2GL's own labels have faded ends), black boxes use D2GL's opacity steps (40/60/80/100 %), and HD text's glyph outline makes red/blue text on a pure white box look lighter.

Default renderer (DirectDraw) D2GL HD text: every variant (opacity 25-100, frames, sizes)
ddraw probe

Filter used for the top screenshot:

ItemDisplay[RUNE=30]: %RED%Ber Rune%BG-20%%OPACITY-100%%FRAME-0A%%BEAM-0A%%MAP-0A%%ICON-STAR%
ItemDisplay[RUNE=31]: %WHITE%Jah Rune%BG-0A%%OPACITY-100%%FRAME-20%%BEAM-0A%%MAP-0A%%ICON-STAR%
ItemDisplay[RUNE=28]: %WHITE%Lo Rune%BG-62%%OPACITY-100%%FRAME-20%%BEAM-62%%MAP-62%%ICON-DIAMOND%
ItemDisplay[RUNE=29]: %WHITE%Sur Rune%BG-00%%OPACITY-100%%FRAME-62%%MAP-62%%ICON-DIAMOND%
ItemDisplay[RUNE=27]: %ORANGE%Ohm Rune%BG-00%%OPACITY-75%%FRAME-60%%MAP-60%%ICON-TRIANGLE%
ItemDisplay[RUNE=24]: %YELLOW%Ist Rune%BG-00%%OPACITY-50%%FRAME-0C%%MAP-0C%%ICON-CIRCLE%
ItemDisplay[UNI]: %NAME%%BG-00%%OPACITY-75%%FRAME-0D%%MAP-0D%%ICON-CROSS%
ItemDisplay[SET]: %NAME%%BG-00%%OPACITY-75%%FRAME-84%%MAP-84%%ICON-SQUARE%
ItemDisplay[]: %NAME%

Not tested: online play (single player only), and the token names are a proposal; happy to rename them to whatever fits PD2's filter docs and the filter authors.

Side findings (not changed here)

  • BH's permShowItems3/4 patches go into D2Client 0x58FB0, which PD2 never calls, so they have no effect on PD2.
  • BH::CheckForD2GL only checks that D2GL's exports exist, so BH thinks D2GL is active under -ddraw too (PD2 loads glide3x.dll in both modes; d2glIsReady() tells them apart).

Parse %BG-XX%, %OPACITY-25|50|75|100%, %FRAME-XX%, %SIZE-S|M|L%, %BEAM-XX%,
%FLASH-XX% and %ICON-<shape>% in BuildAction next to the map colour keywords and
strip them from the label text. Malformed values stay in the text as typed, like
other unknown keywords. Palette colours take 1-2 hex digits (8-bit palette).

GetGroundStyle resolves an item's style over all matching rules with %CONTINUE%
semantics (a later rule overrides each field it sets) and caches it per item like
the name and map caches; ResetCaches clears it on filter reload and filter level
change. Filters without the new tokens skip the lookup entirely.

CreateUnitItemInfo moves to ItemDisplay.cpp so GetGroundStyle can build the item
info from a unit outside Item.cpp.
Ground labels are drawn in D2 font 1 (observed in game), not 13, so
%SIZE-S|M|L% map to fonts 1 / 2 / 3 and S keeps today's look.
Ground item labels are drawn from an array the label pass fills (vanilla D2Client 0x58FB0; PD2
redirects that call to its own rewrite in ProjectDiablo.dll, same entry layout): each label is
measured with D2Win #10177 and drawn with D2Win #10013 (box + text). BH hooks both entries and
recognises a label call by its arguments: the text is the name of an entry inside D2Client's or
ProjectDiablo.dll's image and the other arguments are that entry's fields. For an item with a
GroundStyle the measurement uses the style's font (so the engine sizes, stacks and hit-tests the
box for it) and the draw gets the background colour, the opacity (25/50/75/100 = D2 draw modes
TRANS25/TRANS50/TRANS75/NORMAL), the font and a 1 px frame on the box edge; a framed label reserves
one more row so the label stacked above it does not cover the frame. The hovered label keeps the
engine's highlight box. Every other call, and labels of items without a style, reach the original
functions with the original arguments.

The entry hooks are written as one locked 8-byte swap. An entry already detoured by another module
(D2GL: jmp rel32 + int3) is accepted when the rest of the prologue matches; the trampoline then
continues at that module's hook. 1.13c only; if a prologue differs the styles stay off and BH says
so once per session. GroundLabels::SetPreLabelCallback lets item beams draw below the labels.
%BEAM-XX% draws a light column above a ground item while it lies there, %FLASH-XX% one that
fades out over 2 s after the item drops (fresh drops only: the client marks them ITEM_NEW;
items already lying in a room that comes into view do not flash). A beam is a pulsing
translucent glow with a half-opaque core and a light pool on the ground, drawn with
D2GFX_DrawRectangle in the TRANS25/TRANS50 draw modes. At most 16 beams, the ones nearest
the player. ItemBeams::Draw runs once per frame from BH's draw hook; a hook that runs before
the ground labels may call it first.

%ICON-<shape>% draws the item's automap marker layers (%BORDER%/%MAP%/%DOT%/%PX%, same
sizes and colours) as a circle, diamond, star, triangle or cross instead of a square. A
lone %DOT%/%PX% shape grows to 6 px so the shape stays readable. Squares take the
unchanged drawing path.
Beams were drawn over the character, inventory and waypoint panels: BH's draw hook runs after
the panels, and so does the pre-label hook (the engine draws the panels before the ground labels
and keeps labels off them by position). Beams now follow the engine's label rule: with a left
panel open (D2Client ScreenCovered 2) only the right half of the screen shows the world, with a
right panel open (1) only the left half, with both (3) nothing; beam rectangles are clipped to
that span at any screen width, and only beams in it count for the 16-beam cap. While the game
menu, hotkey config, help, NPC dialog, skill picker or gold dialog is open no beam is drawn
(items are still tracked, so flashes keep their drop time).
D2GL's hd_text replaces D2Win #10013 with its own label box (black or its
hover blue, the colour argument unused) and draws it in a layer over the
game frame. A restyled label is now drawn with the calls D2GL honours when
D2GL renders with hd_text: the box and frame with D2Gfx #10014 (palette
colour and draw mode in the game frame; black in D2GL's layer) and the text
with D2Win #10150. hd_text off and the other renderers keep the #10013 path.
@errolgr
errolgr force-pushed the feature/filter-visuals branch from dcb7889 to 70a563d Compare October 2, 2026 04:34
@errolgr errolgr changed the title Loot filter: PoE-style ground item visuals (label background, frame, size, beams, automap shapes) (stacked on #98) Loot filter: PoE-style ground item visuals (label background, frame, size, beams, automap shapes) Oct 2, 2026
With one side panel open (inventory, character, ...) D2Client shifts the world view by a quarter
of the screen width (D2Client+0x11C418: -width/4 for a right panel, +width/4 for a left one, set
with ScreenCovered at 0x3FF90) and places ground labels with that shift (0x5912D). Beams were
placed without it, so they stayed where the item was before the panel opened. Beams now use the
same transform as the labels.
The beam is now built from 1 px columns in three stacked translucent layers of the beam colour
(outer glow, inner glow, core), each column cut to its own height on a shallow parabola across the
beam and ending in a checker-dithered tail, so the sides fall off and the top fades without bands.
A white-hot centre line, a white flare and a flickering light pool mark the ground, and two short
sparks rise slowly through the core. (The 8-bit DrawRectangle has no additive mode on DirectDraw:
modes 0/1/2 blend 25/50/75 %, 7/8 are opaque, 3 draws nothing, 4/6 black.)

The foot of the beam (40 px) is now drawn with the world, right after the floor and before the
ground items and units (the call to the floor draw at D2Client 0x8B235 makes the original call and
then draws the feet), so the item and anyone standing in front of it cover it; the rest of the beam
is drawn over the world as before. The site is byte-checked (a call); if it cannot be hooked, beams
are drawn whole as before and BH says so once.
The beam is back to the narrow needle of the first version (13 px at the foot, tapering to 1 px),
kept as 1 px columns with dithered tails so it stays smooth; the core, a faint white centre line
and the rising sparks are scaled to it. The light pool and flare are gone.

The beam no longer reaches down to the item: it starts in the middle of the item's ground label,
so the label covers its foot and no beam shows below the label. While the labels are drawn the
label's actual box is used (GroundLabels::LabelBoxOf: the entries measured in this label pass, a
label the engine moved up to make room is followed); without labels the beam starts where the
label would sit (14 px above the ground point), so it does not move when Alt is pressed. With the
item no longer under the beam, the world-draw hook that put the beam's foot under ground items is
removed.
…ise higher

The thin needle gets back the foot of the first beams, scaled to it: a small flickering light
pool (rows -3 .. +2 around the foot, a little wider than the needle) and a white flare. The beam
is a quarter taller (190 px).

The foot is one fixed point, whether labels are shown or not, so the beam never moves when Alt is
pressed: 14 px above the item's ground point, the middle of the box the engine draws an unstacked
ground label in (box 22 .. 6 px above the ground point), so a shown label covers the foot, pool
and flare. A label the engine stacks elsewhere does not cover its beam's foot. The per-frame label
box lookup (GroundLabels::LabelBoxOf) is gone.
PD2's D2GL renderer (motion_prediction, on by default) draws the world between the game's 25 fps
ticks at the player's predicted position: every world sprite is drawn at its position minus the
prediction offset, and ProjectDiablo.dll's ground label pass subtracts the same offset from the
labels (d2glGetGlobalXOffset/d2glGetGlobalYOffset while d2glIsReady). The rectangles BH draws are
not moved by D2GL, so beams stood at the tick position and wobbled against their item while the
player walked. Beams now subtract that offset too, read from the same D2GL exports; it is 0
without D2GL, when D2GL does not render and with motion_prediction off.
The engine marks the label under the mouse (and the item the player walks to)
by drawing it with its opaque blue box (draw mode 5). A restyled label took
that box instead of its %BG%/%OPACITY% background, so hovering it, with Alt
held or "Always Show Items" on, turned it into the stock hover look. Now a
hovered restyled label keeps its background colour, opacity, frame and font
and gets a 1 px white ring inside its frame (on the box's edge without one),
on the default renderers and under D2GL's hd_text alike. Labels without a
style keep the engine's hover look; measurement and hit-testing are unchanged.
…nd labels

New loot filter keywords set how a ground item's label looks while it is
hovered (the label under the mouse with Alt held or "Always Show Items" on):
%HOVERBG-XX% (palette colour), %HOVEROPACITY-25|50|75|100%, %HOVERFRAME-XX%
and %HOVERTEXT-<colour>% with BH's colour words (WHITE RED GREEN BLUE GOLD
GRAY BLACK TAN ORANGE YELLOW PURPLE DARK_GREEN CORAL SAGE TEAL LIGHT_GRAY),
which draws the whole label text in that colour. They are parsed like the
other visual keywords: removed from the label text, case-insensitive, shown as
typed when malformed, usable in aliases, and with %CONTINUE% a later rule only
overrides the ones it sets. Each falls back to %BG% / %OPACITY% / %FRAME% (then
the engine's plain box) while hovered; a rule with hover keywords only leaves
the label to the engine until it is hovered.

A hovered restyled label without hover keywords no longer gets a white ring:
it is lightened by white at 25 % opacity over its box, inside its frame, on
the default renderers and under D2GL's hd_text (where a hovered black box,
drawn in D2GL's own layer, also gets a lighter alpha). Labels without a style
keep the engine's hover look; measurement and hit-testing are unchanged.
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