acdream/docs/research/2026-07-17-retail-world-selection-pseudocode.md
Erik 2dd5cb80d2 fix(ui): assess retained items on right click
Port UIElement_ItemList's physical-item right-click branch through the shared retained list. Select and appraise backpack, loot, paperdoll, and shortcut items through their canonical owners, while preventing RMB movement from lifting items or issuing appraisal requests.

Co-authored-by: Codex <codex@openai.com>
2026-07-24 05:55:51 +02:00

11 KiB
Raw Blame History

Retail world selection and vivid target indicator

Oracle: September 2013 EoR named retail client. Cross-checks: the extracted WorldBuilder mesh path (DAT geometry and current part transforms) and ACE object identity/qualities. Neither reference implementation supplies the client picker; the named retail functions below are authoritative.

Mouse selection

SmartBox::find_object @ 0x00451C60

remember the clicked viewport pixel
clear the previous per-frame selection accumulator
arm mouse selection for the next world draw

CPhysicsPart::Draw @ 0x0050D7A0, RenderDeviceD3D::DrawMesh @ 0x005A0860, and Render::GfxObjUnderSelectionRay @ 0x0054C740

for each physics part which reaches the normal visible-mesh draw path:
    reject parts which do not belong to a server object
    transform the one world selection ray into this part's local coordinates
    divide by the part's GfxObj scale (do not change the ray parameter units)

    sphere = gfxObj.drawing_sphere
    if sphere does not intersect the local ray:
        continue

    if a polygon has already won globally and sphereDistance is farther:
        continue

    if sphereDistance is nearer than the current sphere fallback:
        remember this object's id, part index, and sphereDistance

    for polygons in the GfxObj's stored flat DAT order:
        if polygon_hits_ray(polygon, localRay, out t):
            remember only this FIRST polygon hit for this part
            if t is nearer than the current global polygon winner:
                remember this object's id, part index, and t
            break

CPolygon::polygon_hits_ray @ 0x005395E0

if polygon.sides_type == ST_SINGLE (raw value 0)
and dot(ray.direction, polygon.plane.normal) > 0:
    miss                         // back face

denominator = dot(ray.direction, polygon.plane.normal)
if abs(denominator) < 0.0002:
    miss                         // parallel to the plane

t = plane/ray intersection time
if t < 0:
    miss                         // behind the viewer

point = ray.origin + t * ray.direction
normal = normalize(sum(cross(vertex[i] - vertex[0],
                             vertex[i+1] - vertex[0])))
for each ordered edge (previous -> current):
    inward = cross(normal, current - previous)
    if dot(point - previous, inward) < 0:
        miss
return hit

The broad-phase helper is retail CSphere::sphere_intersects_ray @ 0x005377A0: it rejects rays beginning in/on the sphere, requires squared ray direction length at least 0.0002, solves the quadratic in double precision, and chooses the near root when non-negative or the far root otherwise. The ray origin is Render::viewpoint (the camera eye), not the unprojected near-plane point; Render::pick_ray @ 0x0054B610 supplies only its direction.

Render::GetMouseSelectionObjectID @ 0x0054C950

if any polygon hit exists anywhere in the visible draw:
    return the closest polygon winner
if any drawing-sphere hit exists:
    return the closest sphere fallback
return no object

Important consequences:

  • The broad phase is each visible GfxObj part's drawing sphere, not a Setup selection sphere, physics collision shape, fixed creature hit box, or expanded screen rectangle.
  • A polygon hit on any visible part beats every sphere-only fallback.
  • Retail does not ray-test world-cell walls as a second, independent occluder. Occlusion is inherited from the same portal/viewcone draw traversal that decides which parts may call GfxObjUnderSelectionRay.
  • BSPTREE::GetSphere @ 0x005397E0 returns the drawing BSP root sphere exactly; the DAT source is GfxObj.DrawingBSP.Root.BoundingSphere.

Vivid target indicator

VividTargetIndicator::SetSelected @ 0x004F5CE0

if selected id is the player: clear it
if object is player-owned or IN_CONTAINER: clear it
publish the remaining id to SmartBox
if enabled and an id remains:
    color = gmRadarUI::GetBlipColor(selected id)
else:
    hide both indicator roots

VividTargetIndicator::Initialized @ 0x004F6C60

onscreenRoot = child 0x10000038
corners = children 0x10000039, 0x1000003A, 0x1000003B, 0x1000003C
offscreenRoot = child 0x10000045

The constructor resolves twelve source surfaces by calling DBObj::GetByEnum with RenderSurface DBO type 0x0C, client-enum category 0x10000009, and enum values 1..12. Values 1..4 are the four on-screen corners. CopyImage @ 0x004F5DD0 uses the active retail blit mode (s_BlitMethod == 4) to colorize the source art with the same color as the object's radar blip.

SmartBox::GetObjectBoundingBox @ 0x00452E20

resolve the selected id directly through CObjectMaint::GetObjectA
if the object is absent:
    return ObjectNotFound

reset to Render::set_default_view
push the object's position
obtain CPhysicsObj::GetSelectionSphere
if no sphere exists:
    use an origin-centered sphere with radius 0.1 world units

if Render::viewconeCheck says the sphere intersects the full viewport cone:
    project Render::GetViewerBBox's two corners
    return ObjectOnscreen and the resulting screen rectangle

target = normalized selection-sphere center in viewer-local coordinates
if target is in front of the viewer:
    angle = (450 - degrees(atan2(target.screenRight, target.screenUp))) mod 360
else:
    angle = (450 - degrees(atan2(target.screenRight, 0))) mod 360
return ObjectOffscreen and angle

This path has no object-distance test and does not ask whether the selected object participated in the visible world draw. set_default_view also removes the per-portal view cone before the test. Consequently, a live selected object keeps its four corners through walls and at any distance for which it remains in the client object table.

VividTargetIndicator::OnDraw @ 0x004F62B0

if disabled, display-off, or selected id is zero:
    hide both roots
else if status is ObjectOnscreen:
    colorize source images 1..4 with the selected radar-blip color
    use the actual DAT corner width and height
    place the corners just outside the SmartBox screen rectangle
    clamp the assembled indicator to an 8-pixel viewport margin
    show the on-screen root and hide the off-screen root
else if status is ObjectOffscreen:
    choose one authored source image by angle:
        [338,360) or [0,23) -> 6
        [23,68)            -> 7
        [68,113)           -> 9
        [113,158)          -> 12
        [158,203)          -> 11
        [203,248)          -> 10
        [248,293)          -> 8
        [293,338)          -> 5
    colorize it with the selected radar-blip color
    intersect the angle ray from viewport center with the screen edge
    center the image on that intersection
    clamp its top-left to the 8-pixel viewport margin
    hide the on-screen root and show the off-screen image

Installed retail DAT verification pins images 1..4 at 12×12 pixels and images 5..12 at 24×24 pixels. The larger off-screen marker is authored art, not a procedural arrow. The indicator is persistent selection presentation. Retail also has a separate, short-lived click confirmation owned by SmartBox; it is not implemented in CPhysicsPart::Draw or ACCWeenieObject::SetSelected.

Right-click selection and examination

UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820

on right mouse up:
    if mouse movement owns the gesture:
        finish mouse movement and do not examine
    else if instant mouse-look owns the gesture:
        leave mouse-look and do not examine
    else if no higher-priority SmartBox search is pending:
        searchReason = sr_Examine
        FindObject(releaseX, releaseY)

UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ 0x004E5AD0

if a valid object was found for sr_Examine:
    begin the normal SmartBox lighting pulse
    SetSelectedObject(foundObjectId)
    ClientUISystem.ExamineObject(foundObjectId)
else:
    do nothing

The action is release-completed rather than mouse-down-completed so an RMB camera drag does not appraise the object under the release point. Acdream maps the configurable SelectRight action to the same complete-click gesture and keeps SelectionExamine independently configurable (retail default E).

Retained physical-item lists

UIElement_ItemList::ListenToElementMessage @ 0x004E4D50

on ItemList right-click:
    item = UIItem under the mouse
    if item.itemID != 0:
        if this list uses single selection:
            update the list selection
        SetSelectedObject(item.itemID)
        ClientUISystem.ExamineObject(item.itemID)
    else if item.spellID != 0:
        ClientUISystem.ExamineSpell(item.spellID)

gmPaperDollUI::ListenToElementMessage @ 0x004A5C30 repeats the physical-item branch for a worn item resolved through the authored paperdoll click map:

on paperdoll right-click:
    itemID = GetPaperDollItemUnderMouse(mouse)
    if itemID != 0:
        SetSelectedObject(itemID)
        ClientUISystem.ExamineObject(itemID)

Acdream's shared retained UiItemList owns the corresponding physical-item notice. Backpack, side-bag, external-container, paperdoll-slot, and physical toolbar cells provide their selection source and route the request through the same ItemInteractionController appraisal owner used by world SmartBox and the toolbar magnifying glass. RMB motion beyond the shared three-pixel click threshold neither appraises nor begins an item drag.

SmartBox click lighting pulse

UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ 0x004E5AD0

when FindObject succeeds for Select, Examine, Use, Drop, Drag, or TargetedUse:
    if another object is currently pulsing:
        RestoreLighting(previous object)
    SetLighting(found object, luminosity=0.99, diffuse=1.0)  // HIGH
    flipCount = 1
    nextFlip = Timer::cur_time + 0.2 seconds

    continue with the reason-specific select/examine/use/drop behavior

UIElement_SmartBoxWrapper::Global_Loop @ 0x004E5620

if flipCount != 0 and Timer::cur_time >= nextFlip:
    flipCount += 1
    if flipCount < 5:
        lightingMode = ((flipCount & 1) != 0) + 1
        nextFlip = Timer::cur_time + 0.2 seconds
    else:
        flipCount = 0
        lightingMode = RESTORE
    ApplyLighting(pulsing object, lightingMode)

UIElement_SmartBoxWrapper::ApplyLighting @ 0x004E5320

RESTORE: CPhysicsObj::RestoreLighting(object)
LOW:     CPhysicsObj::SetLighting(object, luminosity=0.0,  diffuse=0.35)
HIGH:    CPhysicsObj::SetLighting(object, luminosity=0.99, diffuse=1.0)

The complete cadence is therefore HIGH immediately, LOW at 0.2 seconds, HIGH at 0.4, LOW at 0.6, and authored material lighting restored at 0.8. A delayed frame advances only one step because retail schedules the next flip from the current Timer::cur_time; it does not drain missed intervals.

CPhysicsObj::SetLighting @ 0x00511A80 forwards to every physics part through CPartArray::SetLightingInternal @ 0x00518490. CPhysicsPart::SetLighting @ 0x0050E400 copies the material when needed, then calls CMaterial::SetLuminositySimple followed by CMaterial::SetDiffuseSimple. The modern renderer's equivalent is an instance-scoped material replacement:

lit = luminosity + diffuse * sceneLighting
rgb = sampledTexture.rgb * clamp(lit, 0, 1)

Normal/restored rendering uses (luminosity=0, diffuse=1) in the current object shader. The pulse is distinct from the persistent four-corner indicator and must restart on every successfully resolved SmartBox click, even when the click is consumed by targeted use instead of changing the selected object.