acdream/docs/research/2026-07-11-retail-combat-bar-pseudocode.md
Erik 8e9c538519 fix #208: keep combat bar hidden in peace mode
Treat combat-window visibility as gameplay-state owned so character layout restoration cannot replay a stale visible bit. Reset combat state to NonCombat at character-session entry, matching retail ClientCombatSystem::Begin.

Co-Authored-By: Codex <codex@openai.com>
2026-07-12 22:30:55 +02:00

8.8 KiB
Raw Blame History

Retail basic combat bar — port pseudocode

Sources

  • Named retail decomp:
    • gmCombatUI::RecvNotice_AttackHeightChanged 0x004CC080
    • gmCombatUI::RecvNotice_SetPowerbarLevel 0x004CC0E0
    • gmCombatUI::RecvNotice_DesiredAttackPowerChanged 0x004CC110
    • gmCombatUI::PostInit 0x004CC1A0
    • gmCombatUI::ListenToElementMessage 0x004CC430
    • gmCombatUI::RecvNotice_SetCombatMode 0x004CC620
    • ClientCombatSystem::Begin 0x0056A460
    • ClientCombatSystem::StartPowerBarBuild 0x0056ADB0
    • ClientCombatSystem::GetPowerBarLevel 0x0056ADE0
    • ClientCombatSystem::StartAttackRequest 0x0056C040
    • ClientCombatSystem::EndAttackRequest 0x0056C0E0
    • ClientCombatSystem::UseTime 0x0056C1F0
    • ClientCombatSystem::HandleAttackDoneEvent 0x0056C500
    • ClientCombatSystem::SetRequestedAttackHeight 0x0056C8F0
    • UIOption_Checkbox::UIOption_Checkbox 0x00486A50
    • UIOption_Checkbox::ListenToElementMessage 0x004869A0
    • compute_str_hash 0x00413110
  • Matching retail executable disassembly (acclient.exe v11.4186 + PDB): ATTACK_POWERUP_TIME = 1.0 seconds and DUAL_WIELD_POWERUP_TIME = 0.8 seconds. The named pseudo-C's x87 expression had incorrectly collapsed the division operands.
  • LayoutDesc 0x21000073 (gmCombatUI), cross-checked against the retail UI layout dump. Root is 610×90; basic page is 0x1000005C, desired-power scrollbar is 0x1000004F, embedded meter is 0x10000050, and the high/medium/low buttons are 0x10000057/58/59.
  • Interpretation cross-checks: ACE combat request/power fields and the holtburger DesiredAttackProfile -> attack_request wire path summarized in docs/research/deepdives/r02-combat-system.md.

Initial state

combatMode = NonCombat
buildInProgress = false
attackRequestInProgress = false
attackServerResponsePending = false
requestedAttackPower = 0
requestedAttackHeight = Medium
desiredUiPower = 0.5
repeatAttacking = false

Panel visibility

when combat mode changes:
    if AdvancedCombatUI:
        hide gmCombatUI
    else if mode == Melee:
        set root state Melee (0x10000003)
        show gmCombatUI
    else if mode == Missile:
        set root state Missile (0x10000004)
        show gmCombatUI
    else:
        hide gmCombatUI

The current acdream port binds the basic page because the persisted gameplay default has AdvancedCombatUI = false. The separate advanced-combat surface is still tracked by divergence row AP-110.

Visibility is combat-state-owned, not a user window preference. Per-character window persistence may restore the bar's authored position, but must not restore or save its visible bit: RecvNotice_SetCombatMode is the sole visibility authority. Character-session initialization likewise starts at NonCombat, as shown by ClientCombatSystem::Begin (0x0056A460).

Press / charge / release

SetRequestedAttackHeight(height):
    changed = requestedAttackHeight != height
    requestedAttackHeight = height
    if changed OR no attack request is active:
        StartAttackRequest()

StartAttackRequest():
    target = current attack target
    if target is not attackable: report no target and stop
    attackRequestInProgress = true
    requestedAttackPower = 1
    stop jump and movement
    currentBuildIsAutomatic = false
    if player is ready and no build is active:
        StartPowerBarBuild()

StartPowerBarBuild():
    buildInProgress = true
    buildStartTime = currentTime
    displayedPower = 0

GetPowerBarLevel():
    if no build: return 0
    duration = current stance is DualWieldCombat ? 0.8 seconds : 1.0 second
    return clamp((currentTime - buildStartTime) / duration, 0, 1)

EndAttackRequest(height, explicitPower = -1):
    if no attack request: stop
    attackRequestInProgress = false
    current = GetPowerBarLevel()
    requestedAttackPower = explicitPower >= 0
        ? explicitPower
        : min(desiredUiPower, current)
    if a prior attack is awaiting the server:
        queue this power for AttackDone
    else if desiredUiPower <= current OR repeat attack is active:
        ExecuteAttack(height)
    otherwise:
        UseTime commits on its next eligible tick at requestedAttackPower

UseTime():
    if a build is active:
        current = GetPowerBarLevel()
        publish current to meter
        if request was released AND current >= requestedAttackPower:
            publish min(requestedAttackPower, current)
            if build is not automatic: ExecuteAttack(requestedHeight)
            else stop automatic build

UI messages

mouse-down high/medium/low:
    SetRequestedAttackHeight(High/Medium/Low)

mouse-up high/medium/low:
    EndAttackRequest(the height, -1)

desired-power slider changed with integer position 0..1000:
    desiredUiPower = clamp(position * 0.001, 0, 1)

attack power increase/decrease action:
    move desiredUiPower by one sixth, clamped to 0..1

Both keyboard and retained buttons therefore use one transition-based attack controller. A one-shot click/keypress is not sufficient: down begins charging, up ends the request.

Authored labels and option checkboxes

PostInit():
    repeat = child 0x10000053 as UIOption_Checkbox
    repeat.playerOption = AutoRepeatAttack
    repeat.label = StringInfo(hash("ID_CombatPanelOption_AutoRepeatAttack"))

    autoTarget = child 0x10000054 as UIOption_Checkbox
    autoTarget.playerOption = AutoTarget
    autoTarget.label = StringInfo(hash("ID_CombatPanelOption_AutoTarget"))

    keepInView = child 0x10000055 as UIOption_Checkbox
    keepInView.playerOption = ViewCombatTarget
    keepInView.label = StringInfo(hash("ID_CombatPanelOption_ViewCombatTarget"))

The first two labels are deliberately not authored in LayoutDesc. They are assigned by gmCombatUI::PostInit, so a layout-only renderer must perform that controller binding. Ordinary Type-12 captions and High/Medium/Low use StringInfo property 0x17 and resolve through local.dat table 0x23000001.

UIOption_Checkbox is a toggle UIElement_Button. Its visible 13x13 face is the authored child 0x10000328; the label is embedded in the option object and starts after that face. Mouse release toggles attribute 0x0E and immediately applies the associated player option.

Horizontal scrollbar media roles

track = scrollbar DirectState image
thumb = structural child element 1
live power fill = nested meter child 0x10000050 / image child 2
dark interior texture source = nested meter child 0x100005EF

These roles are identified by authored element ids, never by resolved width. The combat thumb is a 12x14 green jewel; inheritance reflow can make its effective child geometry wide enough that a width heuristic incorrectly tiles the jewel as the background. The base track is 0x060074CA, thumb 0x06001923, live bright-red fill 0x06001200, and dark-red interior texture 0x0600715E.

The three visual layers remain separate. The gray scrollbar track spans the whole authored control. Retail skill-gates element 0x100005EF to Recklessness advancement class Trained or higher when scalar message 0x0A is received. Per the connected visual gate, acdream uses that dark-red media as the always- visible middle baseline (intentional divergence IA-20); the remaining exact skill-gated treatment stays in AP-112. The bright child retains retail's live RecvNotice_SetPowerbarLevel input and spans the entire scrollbar from the absolute left edge, independently of the dark child's inset geometry. The green thumb remains driven by RecvNotice_DesiredAttackPowerChanged.

The meter authors attribute 0x6F = 1. UIElement_Meter constructor and DrawChildren @ 0x0046FBD0 make direction 1 the forward horizontal clip, so the bright texture grows left-to-right from Speed. Direction 3 is the reverse horizontal clip. The importer reads this attribute; it does not infer direction from the combat element id.

The Speed and Power text rectangles are later siblings of the scrollbar. Their inner edges define the visible meter interval so the authored gray track stays behind both captions. Speed's raw property 0x14 value is 2, which is left justification: UIElement_Text::CalcJustification @ 0x00467260 centers only value 1, right-aligns values 3 and 5, and treats every other value as left. Power uses right justification.

The combat subtree inherits from an 800-pixel prototype and reflows into its 610-pixel mounted root on the first layout traversal. Scrollbars consume their DAT children procedurally, so the dark child must retain its own four-edge UiLayoutPolicy; copying only its sprite loses the child geometry. The authored child starts at x=70,w=567 inside the inherited 707-pixel meter and its proportional edge modes reflow to x=50,w=407 inside the mounted 507-pixel bar. This is the wider centered interval that clears the rendered Speed/Power words; using the full 100-pixel text element boxes produced an overly narrow range.