Before this commit, PlayerWeenie used hardcoded ACDREAM_RUN_SKILL
(default 200) and ACDREAM_JUMP_SKILL (default 300) regardless of
the actual character skill. PlayerDescription's skill table HAS
been parsed since Phase H, but the values weren't plumbed into
PlayerMovementController, so a high-Jump character still got the
3-4m default arc instead of their real 5m+ arc, and a low-Jump
character got too much.
GameEventWiring.WireAll gains an optional `onSkillsUpdated`
callback. The PlayerDescription handler scans the parsed skill
table for SkillId 24 (Run) and SkillId 22 (Jump) — ACE Skill enum
ordinals from references/ACE/.../Enum/Skill.cs:11-37 — and fires
the callback with `init + ranks` for each (the holtburger-named
"init" field is the attribute-derived initial component, ranks
is XP-bought additions; closest sane approximation of ACE's
CreatureSkill.Current short of porting Aug + Multiplier + Vitae
chains).
GameWindow stores the most recent values in _lastSeenRunSkill /
_lastSeenJumpSkill and pushes them into the controller at two
points:
* Immediately if _playerController already exists (PD arriving
mid-session, e.g. after a relog).
* Inside EnterPlayerModeNow when constructing a fresh
controller (the auto-entry path: PD always arrives at login
before auto-entry fires, so this is the normal path).
Both sites also log "applied server skills run=X jump=Y" so live
testing can confirm the right values reached the formula.
Console output (ACDREAM_DUMP_VITALS=1) gains a "vitals: PD-skills
run=X jump=Y" line on every PlayerDescription with skill data.
Tests stay 1222 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three follow-up fixes from the 2026-04-25 live verify session.
1. CRITICAL: BuildTell wire field order. Our outbound layout was
[target_name, message] but ACE's GameActionTell.Handle reads
[message, target_name] (verified against
references/ACE/.../GameActionTell.cs:17-18 verbatim). Result: every
/tell since Phase I.3 has been failing with WeenieError 0x052B
(CharacterNotAvailable) because ACE was looking up the message
text as the recipient name. Swapped the field order in
ChatRequests.BuildTell so message is written first; updated the
pinned BuildTell test to expect the corrected layout. The
WorldSessionChatTests round-trip continues to pass since SendTell
delegates to BuildTell.
2. Retail-style FormatEntry. The user asked for the canonical retail
strings:
/say (own): You say, "text"
/say (incoming): Name says, "text"
/tell (own echo): You tell Caith, "text"
/tell (incoming): Caith tells you, "text"
channel: [Trade] +Acdream says, "text"
/shout (own): You shout, "text"
/shout (incoming):Name shouts, "text"
Discriminators: SenderGuid == 0 distinguishes our own outbound
echoes (set by OnSelfSent) from real incoming whispers (carry the
sender's player guid). Sender == "" or "You" distinguishes our own
/say echoes (OnLocalSpeech substitutes "You" when the wire sender
is empty per holtburger client/messages.rs:476-487).
ChatEntry gains a new ChannelName slot so Channel-kind entries
render with the friendly room name ("Trade") instead of "ch 3".
Falls back to "ch {ChannelId}" when ChannelName isn't populated
(legacy ChatChannel inbound or older callers).
3. Suppress optimistic Channel echo. The user saw duplicates per
/trade /lfg in the live trace:
[ch 0] Trade: hello <-- our optimistic
[ch 3] +Acdream: [Trade] hello <-- ACE's TurbineChat broadcast
ACE's TurbineChatHandler at Network/Handlers/TurbineChatHandler.cs
broadcasts EventSendToRoom to ALL recipients in the room including
the sender, so the canonical echo always arrives via 0xF7DE. Drop
the optimistic OnSelfSent for Turbine kinds in GameWindow's
SendChatCmd handler; trust the server. Legacy ChatChannel paths
(Fellowship / Allegiance / Patron / Monarch / Vassals / CoVassals)
keep the optimistic echo because the legacy 0x0147 broadcast may
not always come back to the sender.
Inbound TurbineChat also stops embedding "[Trade] " into the
message text — passes the friendly name out-of-band via the new
channelName parameter on ChatLog.OnChannelBroadcast.
11 tests updated for the new format strings (8 in ChatVMTests, 1 in
ChatVMCombatTests, 1 BuildTell, plus the format additions cover
incoming/outgoing variants per kind). Solution total: 1007 green
(243 + 114 + 650), 0 warnings.
Tells should now actually deliver. Channel echoes show as
[Trade] +Acdream says, "hello" without the duplicate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three post-launch fixes from the 2026-04-25 live verify session.
1. WeenieError display bug. Many ACE WeenieError / WeenieErrorWithString
codes are *informational*, not error-level — the user saw cryptic
"WeenieError 0x051B: General" / "WeenieError 0x051D" at login, but
those decode as "You have entered the General channel." and
"Turbine Chat is enabled." per ACE WeenieError(WithString).cs
templates. New static helper Core/Chat/WeenieErrorMessages.cs maps
~30 high-frequency codes to retail-faithful templates with `_`
placeholder substitution. ChatLog.OnWeenieError now routes through
Format(); unknown codes still fall back to "WeenieError 0xNNNN[: param]"
so nothing is silently lost. New codes can be added in 30 seconds
when the user reports one.
2. Tell target eats trailing punctuation. Retail muscle memory is
"/t Name, message" — comma is the separator. Our split-on-whitespace
pulled "Name," (with comma) as the target, server returned 0x052B
"That person is not available now." because no such character.
ChatInputParser.TryParseTargeted now strips a trailing ,;:.!? from
the target token so "/t Caith, hi" and "/t Caith hi" both work.
Added 7 Theory cases covering each separator + the long-form alias.
3. TurbineChat routing diagnostics. The user's ACE login showed the
"TurbineChatIsEnabled" + "YouHaveEnteredThe_Channel" notifications
for General/Trade/LFG, confirming TurbineChat IS active server-side.
But outbound /g /trade /lfg might still fall back to legacy
ChatChannel (which the server then rejects). Added diagnostic
Console.WriteLines so the next launch shows:
- "chat: SetTurbineChatChannels parsed enabled=true general=0x... ..."
(when ACE sends the 0x0295 channel-id table)
- "chat: outbound TurbineChat General room=0x... cookie=0x... len=N"
(when SendChatCmd routes a Turbine kind through 0xF7DE)
- "chat: outbound legacy ChatChannel Fellowship id=0x... len=N"
(when SendChatCmd uses the legacy 0x0147 path)
- "chat: SendChatCmd kind=General dropped (turbine.Enabled=false no legacy id)"
(when neither path can dispatch — usually means ACE didn't send
0x0295 yet and the kind is Turbine-only)
Sets up Bug 3 (proper outbound TurbineChat for /g /trade /lfg) for
a follow-up commit once the next live trace shows the actual flow.
18 new tests:
- WeenieErrorMessagesTests: 11 covering known templates + fallback.
- ChatInputParserTests: +7 Theory cases for trailing-punctuation strip.
Solution total: 1007 green (114 UI + 650 Core + 243 Core.Net), 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full port of holtburger's TurbineChat sidecar wire path:
- TurbineChat.cs: 0xF7DE codec with three payload variants
(EventSendToRoom S->C, RequestSendToRoomById C->S, Response).
10-field outer header (size_first/blob_type/dispatch_type/
target_type/target_id/transport_type/transport_id/cookie/
size_second + payload).
- UTF-16LE turbine string codec with 1-or-2 byte variable-length
prefix (high bit on first byte signals 2-byte form). Mirrors
holtburger's read_turbine_string / write_turbine_string at
references/holtburger/.../messages/chat/turbine.rs:502-544.
- SetTurbineChatChannels.cs: 0x0295 GameEvent sub-opcode parser
(10 x u32 channel ids). Wired through GameEventDispatcher in
WorldSession ctor; routes to GameEventWiring + TurbineChatState.
- ChatChannelInfo.cs (Core): unified record union with Legacy
(channel id + name) and Turbine (room id + chat type +
dispatch type + name) variants, plus IsSelfEchoChannel
predicate (Tells = false, channels = true so optimistic echo
is suppressed where the server will echo).
- TurbineChatState.cs (Core): Enabled flag + 10 cached room ids
+ NextContextId() cookie counter starting at 1.
- WorldSession adds TurbineChatReceived + TurbineChannelsReceived
events; SendTurbineChatTo outbound builds RequestSendToRoomById
+ sends through SendGameAction. ProcessDatagram dispatches
0xF7DE at the top level.
- GameWindow constructs TurbineChatState, subscribes inbound
EventSendToRoom -> ChatLog.OnChannelBroadcast; extends I.3's
SendChatCmd handler to route Turbine kinds (General/Trade/Lfg/
Roleplay/Society/Olthoi) through TurbineChat first, fall back
to legacy ChatChannel send when state.Enabled == false.
Round-trip golden fixtures from holtburger source verified for
all three payload variants + UTF-16LE strings (short + long
prefix + non-ASCII Cafe + empty) + SetTurbineChatChannels.
26 new tests:
- TurbineChatTests, SetTurbineChatChannelsTests in Core.Net.Tests
- ChatChannelInfoTests, TurbineChatStateTests in Core.Tests
Solution total: 960 green (243 Core.Net + 625 Core + 92 UI).
ACE doesn't run a TurbineChat server, so codec is "ready when
needed" for retail-server-emulating setups. Legacy ChatChannel
fallback continues to work for current ACE-against-acdream play.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces NullCommandBus.Instance in PanelContext with a real
LiveCommandBus when a live session is active. Panels publish
SendChatCmd; the host routes it to the right wire opcode + emits
a ChatLog.OnSelfSent local echo (optimistic; retail-equivalent
for Talk).
Pieces:
- ChatChannelKind enum (UI.Abstractions) - mirrors holtburger's
ChatChannelKind (references/holtburger/.../client/types.rs:35-49).
- SendChatCmd record (UI.Abstractions) - (Channel, TargetName?, Text).
- LiveCommandBus (UI.Abstractions) - single-handler-per-type;
Register<T> throws on double-register; Publish<T> logs missing
handler but does not throw.
- ChannelResolver (UI.Abstractions) - port of holtburger's
resolve_legacy_channel (client/commands.rs:50-62) mapping
ChatChannelKind to legacy ChatChannel ids verbatim from
holtburger-protocol/.../chat/types.rs:8-24 (Fellow=0x0800,
AllegianceBroadcast=0x02000000, Vassals=0x1000, Patron=0x2000,
Monarch=0x4000, CoVassals=0x01000000).
- WorldSession.SendTalk / SendTell / SendChannel - 3-line wrappers
around existing ChatRequests.Build* + SendGameAction. Internal
GameActionCapture seam + InternalsVisibleTo for tests.
- GameWindow registers SendChatCmd handler: Say -> SendTalk +
ChatLog echo, Tell -> SendTell + echo, channel kinds ->
ChannelResolver.Resolve -> SendChannel + echo.
12 new tests across SendChatCmd + LiveCommandBus + ChannelResolver
+ WorldSessionChat. NullCommandBus.Instance retained for back-compat
when no live session.
Solution total: 893 green (51 + 229 + 613).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three fixes to the Vitals HUD path:
1. EnchantmentMask Vitae/Cooldown bit values (parser regression).
ACE's enum at references/ACE/Source/ACE.Entity/Enum/EnchantmentCategory.cs
has Vitae=0x4 and Cooldown=0x8. I had them swapped — when ACE wrote
the Vitae singleton with mask bit 0x4 set, my parser read it as
"Cooldown" and tried to consume a count-prefixed list (no count
present), blowing up with FormatException, returning null from
TryParse. PlayerDescription consequently failed to parse on every
live login. Fix: swap the bit values + bucket constants to match ACE.
2. Vitae applies regardless of StatModKey. Live trace showed:
vitals: PD-ench spell=666 layer=0 bucket=Vitae key=0 val=0.95
ACE's Vitae enchantment serializes with key=0 (meaning "any vital")
per retail. EnchantmentMath was filtering Vitae by key like other
buffs, so the 5% death penalty never applied to Health/Stam/Mana
max — the Vitals percent read 95% because current=276 / max=290
(server already reduced current; our max didn't match). Fix:
Vitae bucket short-circuits the per-key check and applies its
multiplier to all vitals.
3. Absolute current/max in HUD overlay. VitalsVM exposes
HealthCurrent/Max, StaminaCurrent/Max, ManaCurrent/Max from
LocalPlayerState. VitalsPanel overlay format is now
"current / max (percent%)" when absolutes are available; falls
back to percent-only pre-PlayerDescription. Matches the retail
look the user requested ("HP 400/400" style).
Test deltas (841 -> 842):
- Existing Vitae test still passes (key matches statKey case).
- New Vitae key=0 test pins the "any vital" semantics.
- Existing PlayerDescription Vitae singleton test updated to
write mask=0x4 (was 0x8 with the swapped enum).
Live verification: with +Acdream's Vitae-666 active and Endurance.current=290:
HP : current=138, max=145×0.95≈138 → bar 100% (was 95%)
Stam : current=276, max=290×0.95≈276 → bar 100%
Mana : current=190, max=200×0.95≈190 → bar 100%
Overlay reads e.g. "276 / 276 (100%)".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends PlayerDescriptionParser past the spell block to parse the
Enchantment trailer per holtburger events.rs:462-501 +
magic/types.rs:40. New EnchantmentEntry record carries the full
60-64 byte wire payload:
u16 spell_id, layer, spell_category, has_spell_set_id
u32 power_level
f64 start_time, duration
u32 caster_guid
f32 degrade_modifier, degrade_limit
f64 last_time_degraded
u32 stat_mod_type, stat_mod_key
f32 stat_mod_value
[u32 spell_set_id]?
+ EnchantmentBucket (Multiplicative / Additive / Cooldown / Vitae)
EnchantmentMask outer u32 selects which buckets follow; each bucket
(except Vitae) is u32 count + N records. Vitae is a singleton.
Parsed.Enchantments now exposed as IReadOnlyList<EnchantmentEntry>.
GameEventWiring routes each entry through Spellbook.OnEnchantmentAdded
with the full StatMod data + bucket. EnchantmentMath.GetMod consumes
StatMod records to produce real (Multiplier, Additive) per stat key:
Bucket 1 (Multiplicative): multiplier *= val
Bucket 2 (Additive): additive += val
Bucket 8 (Vitae): multiplier *= val (applied last)
Bucket 4 (Cooldown): skipped (not a vital mod)
ActiveEnchantmentRecord extended with optional StatModType /
StatModKey / StatModValue / Bucket fields. Existing 4-arg callers
stay compatible (defaults to null / 0). New OnEnchantmentAdded
overload accepts the full record from PlayerDescription path.
Tests: 7 new (834 -> 841):
- PlayerDescriptionParserTests (2): enchantment block schema with
multiplicative + additive buckets, Vitae singleton.
- EnchantmentMathTests (5): multiplicative buffs aggregate, additive
buffs sum, stat-key mismatch filters out, Vitae applied
multiplicatively, family-stacking picks higher spell-id.
Closes#7 (parser past spells, enchantment block parsed).
Closes#12 (StatMod flow architecture — data lights up #6's
aggregator). Files #13 (remaining trailer sections: options /
shortcuts / hotbars / desired_comps / spellbook_filters / options2 /
gameplay_options / inventory / equipped — needs the heuristic
gameplay_options walker per holtburger).
Note: ParseMagicUpdateEnchantment (live-update 0x02C2) NOT yet
extended — still uses 4-field summary. PlayerDescription is the
load-bearing path for #6; live updates can be folded in separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files four new issues created by the 2026-04-25 PDB-discovery sprint:
#8 (DONE 2026-04-25) — pdb-extract tool, shipped 69d884a#9 (OPEN) — function-map address-correction sweep
(Phase E will close)
#10 (DONE 2026-04-25) — wire KillerNotification (0x01AD); orphan
parser at GameEvents.ParseKillerNotification
existed but was never registered. This commit
adds CombatState.OnKillerNotification +
KillLanded event, registers the dispatcher
handler, and adds a regression test.
#11 (OPEN) — spell metadata loader (spells.csv → SpellTable)
(Phase F will close)
Code change is minimal — three lines of dispatch + a 12-line
CombatState method with a typed event for future killfeed UI.
818 tests passing (+1 KillerNotification).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Visual-verified — Vitals window now shows three bars (HP/Stam/Mana)
with live values. Closes ISSUES.md #5; ~95% reading on Stam/Mana
traced to active buff multipliers, filed as #6.
Why the rewrite
The first attempt (commit d42bf57) routed PlayerDescription (0x0013)
through AppraiseInfoParser, trusting a misleading xmldoc claim.
Live diagnostics proved the format is wrong — ACE source
(GameEventPlayerDescription.WriteEventBody) hand-writes a body
distinct from IdentifyObjectResponse's AppraiseInfo: property
hashtables gated on DescriptionPropertyFlag, vector-flag-gated
attribute / skill / spell blocks, then a long options + inventory
trailer. Vitals only arrive via the attribute block at login.
Holtburger's events.rs:220-625 has the canonical client-side
unpacker; this commit ports the early-section walker through spells.
What landed
PlayerDescriptionParser.cs (new — 350 LOC):
Walks propertyFlags + weenieType, then property hashtables
(Int32/Int64/Bool/Double/String/Did/Iid) + Position table —
each gated on a property flag bit, header is `u16 count, u16
buckets`. Then vectorFlags + has_health + the attribute block
(primary attrs 1..6 = 12 B each, vitals 7..9 = 16 B with
`current`), then optional Skill + Spell tables. Stops cleanly
before the options/shortcuts/hotbars/inventory trailer (filed
as #7 — heuristic alignment search needed for gameplay_options).
PrivateUpdateVital.cs (new — 95 LOC):
Wire parsers for the GameMessage opcodes 0x02E7 (full snapshot)
and 0x02E9 (current-only delta), per holtburger UpdateVital +
UpdateVitalCurrent. WorldSession dispatches each to a session-
level event the GameWindow forwards into LocalPlayerState.
LocalPlayerState (full redesign):
VitalKind (Health/Stamina/Mana) + AttributeKind (six primary).
VitalSnapshot stores ranks/start/xp/current; AttributeSnapshot
stores ranks/start/xp with `Current = ranks+start` per
holtburger. GetMaxApprox computes the retail formula
vital.(ranks+start) + attribute_contribution
where the contribution is hardcoded from retail's
SecondaryAttributeTable: Endurance/2 for Health, Endurance for
Stamina, Self for Mana. Enchantment buffs not yet folded in
(filed as #6). VitalIdToKind now accepts both ID systems
(1..6 wire, 7..9 PD attribute block); AttributeIdToKind covers
primary attrs 1..6.
GameEventWiring:
PlayerDescription handler. Walks parsed.Attributes, routes
primary attrs (id 1..6) to OnAttributeUpdate and vitals
(id 7..9) to OnVitalUpdate. Player's full learned spellbook
also lands here. ACDREAM_DUMP_VITALS=1 traces every PD attribute
+ every PrivateUpdateVital(Current) opcode for diagnostics.
WorldSession:
Dispatch chain re-ordered — the diagnostic else-if for
ACDREAM_DUMP_OPCODES=1 was originally placed before
GameEventEnvelope.Opcode, which silently intercepted 0xF7B0 and
broke UpdateHealth dispatch when the env var was set. Moved to
the very end of the chain so it only fires for genuinely
unhandled opcodes. (Diagnostic-only regression; production
launches without the env var were unaffected.)
Test deltas
Added:
- PlayerDescriptionParserTests (6 — empty header, full attribute
block, partial flags, post-property-table walk, spell table)
- PrivateUpdateVitalTests (7 — fixture round-trip, vital ID
coverage, opcode rejection, truncation)
- LocalPlayerStateTests rewritten (20 — VitalIdToKind +
AttributeIdToKind theories, Endurance/Self formula coverage,
delta semantics, change events)
- GameEventWiringTests for PlayerDescription dispatch (2 —
end-to-end populate + spellbook feed)
Updated:
- VitalsVMTests rephrased onto the new OnVitalUpdate API.
Total: 765 → 817 tests passing.
Diagnostics
ACDREAM_DUMP_VITALS=1 — log every PD attribute extracted,
every 0x02E7/0x02E9 dispatch.
ACDREAM_DUMP_OPCODES=1 — log first occurrence of any unhandled
GameMessage opcode (now correctly placed at end of chain).
Visual verify
$env:ACDREAM_DEVTOOLS = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug
Vitals window shows three bars; HP at 100%, Stam/Mana at ~95%
(the gap is buff enchantments — filed as #6 with the holtburger
multiplier+additive aggregator pattern as the reference for the
fix).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes ISSUES.md #5. The Vitals devtools window now draws three bars
(HP / Stamina / Mana) once the server sends the first PlayerDescription
(0x0013), instead of HP only. Built test-first per CLAUDE.md TDD rule —
16 new tests went red before the implementation went in.
New AcDream.Core.Player.LocalPlayerState (cache):
- {CurrentStamina, MaxStamina, CurrentMana, MaxMana} as uint? — null
until first received.
- StaminaPercent / ManaPercent: 0..1 fraction or null when either
field is missing or max is zero. Clamps to 1.0 if current > max
(server can briefly report this during buff transitions).
- OnPlayerDescription preserves any previously known good value when
an incoming field is null — partial profiles don't wipe state.
- Changed event for future subscribers.
GameEventWiring.WireAll:
- New optional 6th parameter: LocalPlayerState? localPlayer = null.
Existing 5-arg call sites still work; without the parameter the new
PlayerDescription handler still parses + feeds the spellbook but
skips the cache update.
- PlayerDescription (0x0013) shares AppraiseInfo wire format with
IdentifyObjectResponse (0x00C9) per AppraiseInfoParser docstring,
so the new handler reuses the existing parser and pulls
CreatureProfile.{Stamina, StaminaMax, Mana, ManaMax}.
- Player's full learned spellbook also lands here (previously only
item-scoped Identify responses fed the spellbook).
VitalsVM:
- Constructor adds optional LocalPlayerState? parameter (default null
keeps every existing caller compiling).
- StaminaPercent / ManaPercent now read through to LocalPlayerState
every access — no VM-side caching, so a server-side delta to the
cache surfaces next frame without any explicit refresh.
GameWindow:
- Public readonly LocalPlayer field alongside Combat / Chat / Items /
SpellBook so plugins + future panels can bind directly.
- WireAll call updated to pass LocalPlayer.
- VitalsVM construction passes LocalPlayer so the existing
VitalsPanel automatically picks up the two new bars.
Test counts:
- AcDream.Core.Tests: 550 → 561 (+11 LocalPlayerStateTests)
- AcDream.UI.Abstractions.Tests: 23 → 26 (+3 VitalsVM through-cache)
- AcDream.Core.Net.Tests: 192 → 194 (+2 PlayerDescription wiring)
- Total: 765 → 781
Build: 0 warnings, 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports retail's AdminEnvirons (opcode 0xEA60) — the client-visible
weather-event channel distinct from the PlayScript path. Wire format
(chunk_006A0000.c: `[u32 opcode][u32 environChangeType]`).
EnvironChangeType range:
0x00..0x06 — fog presets (Clear/Red/Blue/White/Green/Black/Black2)
0x65..0x75 — one-shot ambient sounds (Roar, Bell, Chant, etc)
0x76..0x7B — Thunder1..6 sounds (paired with a lightning PlayScript)
Dispatch:
- WorldSession decodes the packet, fires EnvironChanged event.
- GameWindow.OnEnvironChanged:
* Fog values (0x00..0x06) → WeatherSystem.Override. The enum
values line up byte-for-byte with our EnvironOverride enum
(deliberately mirrored from retail), so a direct cast works.
* Sound values (0x65..0x7B) → console log with retail name for
now. Actual OpenAL playback needs a EnvironChangeType →
WaveData lookup (indexed via SoundTable dat), which is a
separate follow-up. The event still fires so any future
audio subscriber can plug in.
Combined with Phase 6a-6c PhysicsScript/PlayScript wiring, the
complete retail lightning pipeline is now:
server sends PlayScript(0xF754, lightningGuid, scriptId=0x33xxxxxx)
→ runs the flash script via PhysicsScriptRunner
→ CreateParticleHook spawns the flash particles
server sends AdminEnvirons(0xEA60, Thunder3Sound=0x78)
→ OnEnvironChanged logs; audio binding TBD
Whether the user's ACE sends these packets depends on the server
(ACE 2.x vanilla does NOT — Agent #5 verified no lightning opcodes in
the default emit path). With the client port complete, any ACE mod
or extension that emits the right packets will Just Work in acdream.
Build + 742 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 6b — WorldSession now dispatches the PlayScript opcode (0xF754)
that retail uses for all server-triggered client-side visual effects.
Wire format per Agent #5 decompile (chunk_006A0000.c:12320-12336):
[u32 opcode=0xF754][u32 targetGuid][u32 scriptId]
New event `PlayScriptReceived(uint guid, uint scriptId)` fires on
every matching fragment. Unknown payloads (body < 12 bytes) are
silently ignored.
Phase 6c — GameWindow instantiates a PhysicsScriptRunner at startup,
subscribes to PlayScriptReceived, and ticks the runner every frame
BEFORE the ParticleSystem tick so a CreateParticleHook fired this
frame gets its emitter integrated in the same frame.
Anchor policy: use the camera's world position for the script anchor.
For Dereth-wide storm effects (lightning flashes) the camera is the
right reference frame — the flash is "around the player." Per-entity
effects (spell casts, emotes) dedupe by (scriptId, entityId) so
multiple simultaneous plays on different guids work; a follow-up will
look up the guid's last-known world pos from _worldState for accurate
per-entity anchoring.
The full pipeline now for a lightning flash:
1. ACE (or other retail-emulating server) sends
GameMessage(0xF754, lightningGuid, scriptId=0x33xxxxxx).
2. WorldSession parses: PlayScriptReceived event fires.
3. GameWindow.OnPlayScriptReceived routes to _scriptRunner.Play.
4. Runner loads the PhysicsScript from the dat, schedules every
(StartTime, AnimationHook) entry.
5. Per-frame Tick fires each hook at its scheduled time via
ParticleHookSink — CreateParticleHook spawns a particle emitter
(the flash), SoundHook plays thunder audio (Phase 5d), etc.
Set ACDREAM_DUMP_PLAYSCRIPT=1 to see each inbound PlayScript and each
hook fire as `[pes]` log lines — useful for identifying which script
IDs your ACE server actually sends.
Build + 742 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports the retail client's client-side remote-entity motion pipeline
verbatim per the decompile research. Every remote now runs its own
PhysicsBody + MotionInterpreter + AnimationSequencer stack — retail has
no special "interpolator" for remotes, it runs the full motion state
machine on every entity. Now we do too.
## What changed
### Parser fixes (CreateObject, UpdateMotion)
Wire flag bits for InterpretedMotionState (per ACE MovementStateFlag enum):
CurrentStyle=0x01, ForwardCommand=0x02, ForwardSpeed=0x04,
SideStepCommand=0x08, SideStepSpeed=0x10, TurnCommand=0x20, TurnSpeed=0x40
Previously we only extracted CurrentStyle + ForwardCommand + ForwardSpeed
and SKIPPED the side/turn fields entirely. Result: we had zero rotation-
or strafe-intent data from the server — impossible to render turn or
sidestep animations. Now ServerMotionState carries all 7 fields and the
parser reads the bytes in ACE's write order (style, fwd, side, turn, then
fwdSpd, sideSpd, turnSpd).
### RemoteMotion (new per-remote struct in GameWindow)
Each remote gets its own PhysicsBody + MotionInterpreter + observed
angular velocity. Replaces the earlier shortcut RemoteInterpolator
(deleted — retail has no such thing).
On UpdateMotion:
- ForwardCommand flag absent → stop signal (reset to Ready) per
retail FUN_0051F260 bulk-copy semantics (absent = Invalid = default).
- Forward + sidestep + turn each route through DoInterpretedMotion,
exactly as retail FUN_00528F70 does.
- Animation cycle selection: forward wins if active, else sidestep,
else turn, else Ready. Matches the user's observation that retail
plays turn animation when only turning.
- Turn command seeds ObservedOmega = π/2 × turnSpeed (from Humanoid
MotionData.Omega.Z ≈ π/2 per decompile).
- Turn absent → ObservedOmega = 0 (stops rotation immediately).
On UpdatePosition:
- Hard-snap Body.Position + Body.Orientation per retail FUN_00514b90
set_frame (direct assignment, no slerp — retail does not soft-snap).
- HasVelocity + |v| < 0.2 → StopCompletely + SetCycle(Ready).
- ForwardSpeed=0 on wire is a VALID stop signal (ACE sends this when
alt releases W); previously we defaulted to 1.0, causing the "slow
walk that never stops" symptom.
Per-tick:
- apply_current_movement → Body.Velocity via get_state_velocity
(retail FUN_00528960: RunAnimSpeed × ForwardSpeed in body-local,
rotated by orientation).
- Manual omega integration: Orientation *= quat(ObservedOmega × dt).
Bypasses PhysicsBody.update_object's MinQuantum=1/30s gate that
was eating every-other-tick rotation updates at our 60fps render
rate — the cause of the persistent "rotation snaps every UP" bug.
- update_object still called for position integration and the motion
subsystem it drives.
### AnimationSequencer synthesis extension
Added omega synthesis for TurnRight/TurnLeft cycles (same pattern as
the earlier velocity synthesis): when the Humanoid dat leaves HasOmega
clear, SetCycle synthesizes CurrentOmega = ±π/2 × speedMod on Z so
dead-reckoning and stop detection can read a non-zero omega for turn
cycles.
### Stop-detection heuristic removed
No more 300ms/2000ms/5000ms idle timers. Retail's stop signal is
explicit (UpdateMotion with ForwardCommand flag absent → Ready); we
handle it directly. Client-side timers were a source of flicker during
normal running.
## Confirmed working
- Walking (matches retail speed + leg cadence)
- Running (matches retail speed + leg cadence)
- Strafing (body moves sideways + strafe animation plays)
- Turning while stationary (body rotates smoothly + turn animation plays)
- Turning while running (body rotates + leg anim continues)
- Stopping (instant stop, no slow-walk tail)
All 717 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships full retail-faithful sky-object rendering, 5-kind weather with
deterministic per-day roll + storm lightning, dynamic-lighting shader
UBO with retail hard-cutoff semantics, per-entity torch LightSource
registration via Setup.Lights, ParticleRenderer for rain/snow, and
TimeSync handshake wiring. F7 / F10 debug keys for time/weather
cycling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WorldSession now surfaces the server's PortalYearTicks via a new
ServerTimeUpdated event, fired from two sources per r12 §12:
1. Initial ConnectRequest handshake (ConnectRequestServerTime field
of the optional block — seeds the clock on login).
2. Every subsequent packet carrying the TimeSync header flag
(0x01000000) — keeps the client clock within one TimeSync period
of authoritative server time.
GameWindow subscribes the event into WorldTimeService.SyncFromServer,
so the day/night cycle + keyframe interpolation runs from real server
time in live mode. Offline mode (ACDREAM_LIVE=0) still uses the
seeded-to-noon fallback from OnLoad.
DebugOverlay now exposes sky + weather + lighting state:
time 0.50 Midsong (day fraction + hour name)
wx Clear parts 0 (weather kind + live particle count)
lit 1/4 (active / registered lights)
F7 cycles a debug time override through
(none → midnight → dawn → noon → dusk → none)
F10 cycles weather through
(Clear → Overcast → Rain → Snow → Storm).
These keybinds satisfy the visual-verification tier so a user can
flip through every state from the running client without touching
the code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UpdateMotion's InterpretedMotionState payload includes not just
ForwardCommand but a whole Commands[] list of MotionItem entries — each
carrying an Action (attack, portal, skill use), Modifier (jump,
stop-turn), or ChatEmote (Wave, BowDeep, Laugh) that should overlay the
current cycle. The old parser stopped reading after ForwardSpeed, so
emotes/attacks/deaths never reached the sequencer and NPCs just sat in
their idle cycle.
Three parts:
1. New MotionItem wire record in ServerMotionState — carries Command
(u16), PackedSequence (u16 with IsAutonomous bit + 15-bit stamp),
and Speed (f32). Mirrors ACE Network/Motion/MotionItem.cs.
2. Both UpdateMotion.TryParse and CreateObject.TryParseMovementData
now read the full InterpretedMotionState: all 7 flag fields
(CurrentStyle, ForwardCommand, SidestepCommand, TurnCommand,
ForwardSpeed, SidestepSpeed, TurnSpeed) plus the numCommands ×
MotionItem tail. The packed u32 encodes flags in low 7 bits and
command count in bits 7+ (see ACE InterpretedMotionState.cs:131).
3. New MotionCommandResolver — reconstructs the 32-bit MotionCommand
class byte from a 16-bit wire value via a reflection-built lookup
of DatReaderWriter.Enums.MotionCommand. Server serializes as u16
(ACE InterpretedMotionState.cs:139) and we need the class to route:
- 0x10xxxxxx Action / 0x20xxxxxx Modifier / 0x12,0x13 ChatEmote →
PlayAction (resolves from Modifiers or Links dict, overlays on
current cycle)
- 0x40xxxxxx SubState → SetCycle (cycle change)
4. OnLiveMotionUpdated in GameWindow dispatches each command:
- SubState class (0x40xxx) → SetCycle (treated same as
ForwardCommand)
- Action/Modifier/ChatEmote → PlayAction — the link animation
plays once then drops back to the current cycle naturally
(matches retail's action-queue pattern in CMotionInterp
DoInterpretedMotion, decompile FUN_00528F70).
Result: NPCs now animate attacks, waves, bows, death throes, and other
one-shots that ACE broadcasts via the Commands list rather than the
primary ForwardCommand field. Combined with the dead-reckoning + speed-
scaling from the prior commits, remote characters look visually correct
during the full motion spectrum (idle → walk → run → attack → death).
Tests: 2 new UpdateMotion wire-format tests (ForwardSpeed parse, full
Wave command list parse) + 19 new MotionCommandResolver reconstruction
tests covering SubState, Action, and ChatEmote classes. 654 tests green
(was 633).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause found from ACE source:
- Player_Tick.cs:368 — "the client will never send a 'client released
forward' MoveToState in this scenario unfortunately"
- Server therefore can't broadcast a MotionCommand.Ready UpdateMotion
when a remote player stops moving.
- Retail observer infers stopped state from position deltas going to
zero, not from an explicit motion message.
Also found + fixed the UpdateMotion parser's 2-byte offset bug: ACE's
Align() pads based on absolute stream length (length=15 → 1 pad byte),
not relative-to-block. Previous parser assumed 3 pad bytes after the
MovementData header, which mis-aligned every subsequent field by 2.
After fix, stance/command/speed decode correctly for both server-
controlled NPCs (full stance 0x003D + cmd transitions) and remote
players (stance=0 meaning "no change" + per-axis commands).
OnLiveMotionUpdated rewrite: use SetCycle directly for sequencer
entities instead of routing through GetIdleCycle (which ignored
command when stance was 0). Preserve current style/motion when the
server omits a field ("no change" semantics). Reconstruct full
MotionCommand high byte from current motion or SubState mask.
Remote stop-detection: new _remoteLastMove dict tracks per-entity last
meaningful position + time. OnLivePositionUpdated updates only on
moves > 0.05m so the timestamp captures last actual movement.
TickAnimations checks every entity in a locomotion cycle; if their
last-move time is >400ms stale, swap sequencer to Ready. Excludes
player's own entity (driven by local input, not server observation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GameEventWiring now registers a handler for
GameEventType.IdentifyObjectResponse (0x00C9) that:
1. Runs AppraiseInfoParser.TryParse to extract the full property bundle.
2. If the item is in the repository, merges the bundle into its
Properties via ItemRepository.UpdateProperties (fires
ItemPropertiesUpdated).
3. Merges any SpellBook entries into Spellbook.OnSpellLearned (caster
weapons list their cast-on-use spells; PlayerDescription reuses the
same container for the player's learned set).
Effect: when the player clicks "Appraise" on an item, the tooltip
panel can read full property detail from ItemInstance.Properties
immediately after the server replies.
Build + 628 tests still green. No new test file needed — existing
AppraiseInfoParser tests cover the parse path; GameEventWiring round-
trip tests cover the dispatch path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the single biggest P0 gap from r08: the AppraiseInfo blob
carried by both IdentifyObjectResponse (0x00C9) and the initial
PlayerDescription (0x0013) is now parsed end-to-end for the six core
property tables.
Wire layer:
- AppraiseInfoParser.TryParse returns a Parsed record:
(Guid, Flags, Success, PropertyBundle, SpellBook[]).
- IdentifyResponseFlags enum mirrors ACE's bitfield exactly.
- Header reader: u16 count + u16 numBuckets (ACE
PackableHashTable.WriteHeader format).
- Per-table readers: IntStatsTable, Int64StatsTable, BoolStatsTable
(u32 → bool), FloatStatsTable (f64 values), StringStatsTable
(string16L values with 4-byte pad), DidStatsTable.
- SpellBook reader: u32 count followed by count u32 spell ids, with
sanity cap at 4096 entries.
What's NOT yet parsed (deferred, noted in XML doc):
- ArmorProfile / CreatureProfile / WeaponProfile / HookProfile blobs
require porting their respective Structure classes.
- Enchantment bitfields (u16 highlight + u16 color triplets).
- ArmorLevels block.
The parser is defensive: malformed / truncated tables raise
FormatException which is caught internally; the caller gets
whatever properties parsed successfully before the error.
Tests (7 new):
- Header-only (no tables).
- IntStatsTable round-trip with mixed sign values.
- BoolStatsTable (u32 ↔ bool conversion).
- StringStatsTable with padded-length strings.
- SpellBook parsing.
- Combined flags across multiple tables.
- Truncated payload → null.
Build green, 628 tests pass (up from 621).
This unlocks the Attributes / Skills / Paperdoll UI panels once their
renderers land — every property key the server sends now gets stored
on the target ItemInstance (or — for PlayerDescription — the player's
own property bag once wired).
Ref: ACE AppraiseInfo.Write (AppraiseInfo.cs:735), PackableHashTable.
Ref: r08 §4 payload for 0x00C9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Outbound GameActions for XP-spending + combat-mode-change. These
complete the wire surface for the character-sheet UI: the player
clicks "spend XP on Strength," the panel calls BuildRaiseAttribute,
the session sends it, the server responds with updated PlayerDescription
or PrivateUpdateAttribute GameEvents.
Wire layer:
- BuildRaiseAttribute (0x0045): attrId u32, xpSpent u64.
- BuildRaiseVital (0x0044): vitalId u32, xpSpent u64.
- BuildRaiseSkill (0x0046): skillId u32, xpSpent u64.
- BuildTrainSkill (0x0047): skillId u32, credits u32 (note: credits
is u32 here, NOT u64 like the xpSpent variants).
- BuildChangeCombatMode (0x0053): mode enum as u32
(Undef=0, NonCombat=1, Melee=2, Missile=3, Magic=4, Peaceful=5).
Tests (5 new): byte-exact encoding of each, including the Train/
Raise size difference due to u32 vs u64 payloads.
Build green, 621 tests pass (up from 616).
Ref: r08 §3 rows 0x0044 / 0x0045 / 0x0046 / 0x0047 / 0x0053.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Click-to-interact wire layer. Adds the three most common "do a thing
to an object" GameActions that the UI triggers on left-click / use-item
contexts.
Wire layer:
- InteractRequests.BuildUse (0x0036): single target guid — click a
door, loot a corpse, talk to an NPC, activate a lifestone, step on
a portal.
- InteractRequests.BuildUseWithTarget (0x0035): source + target —
key on locked door, scroll on self, salvage tool on item.
- InteractRequests.BuildTeleToLifestone (0x0063): no-arg recall. Fails
server-side if not tied; reply comes back as GameEvent WeenieError.
Server reply for Use + UseWithTarget is GameEventType.UseDone (0x01C7)
carrying a WeenieError code (0 = success). Already parsed; wiring
into a "UseDone" event on CombatState-style holder can be a follow-up.
Tests (3 new): byte-exact encoding of all three builders.
Build green, 616 tests pass (up from 613).
Ref: r08 §3 rows 0x0035/0x0036/0x0063.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Client-side allegiance data model + outbound swear/break actions.
The inbound AllegianceUpdate blob (0x0020) is complex and is deferred;
the tree API here is designed so the handler can push nodes in when
the blob parser lands.
Wire layer:
- AllegianceRequests.BuildSwear (0x001D): single uint32 patronGuid.
- AllegianceRequests.BuildBreak (0x001E): single uint32 targetGuid
(works for both breaking from patron and breaking away a vassal;
the server picks behavior based on the relationship).
Core layer (AcDream.Core/Allegiance):
- AllegianceNode: Guid, Name, PatronGuid, Rank (clamped 0..10),
VassalGuids list.
- AllegianceTree: Dictionary-backed, events on TreeChanged.
- SetMonarch: registers the root (no patron).
- UpsertNode: adds/refreshes + auto-inserts into parent's vassal list.
- RemoveNode: removes from parent list too; descendants are left with
dangling patron pointers for the UI to hide (next AllegianceUpdate
refreshes).
- GetAncestors: walks up to monarch, cycle-detected for defense.
- GetDescendants: BFS-order flattening.
- AllegianceMath.ComputePassup: retail XP formula
(50+22.5×loyalty)/291 × (1+RT/730×IG/720) × earned,
clamped at 0.
Tests (11 new):
- Tree: SetMonarch fires TreeChanged, UpsertNode auto-populates parent
vassal list, rank clamp at 10, RemoveNode cleans parent list,
GetAncestors chain, cycle-safe walk, GetDescendants BFS order.
- Math: Passup known-value check (1000 XP, 10 loyalty, 100 RT/IG
days → ~963 XP), negative clamp.
- Wire: Swear + Break byte-exact encoding.
Build green, 613 tests pass (up from 602).
Next: wire inbound AllegianceUpdate (0x0020) + AllegianceInfoResponse
(0x027C) handlers once the blob parser lands. Chat "Allegiance"
Turbine channel joining (r11 §2.1 step 9) layers on top of
Phase H.1 chat infrastructure.
Ref: r11 §1 (tree structure + rank cap), §2 (swear/break wire),
§3.2 (XP passup formula).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Central registration helper that wires every parsed GameEvent from the
Phase F.1 dispatcher into the appropriate Core state class:
- ChannelBroadcast / Tell / CommunicationTransientString / PopupString
→ ChatLog (H.1)
- UpdateHealth / Victim / Defender / Attacker / EvasionAttacker /
EvasionDefender / AttackDone → CombatState (E.4)
- MagicUpdateSpell / MagicRemoveSpell / MagicUpdateEnchantment /
MagicRemoveEnchantment / MagicDispelEnchantment /
MagicPurgeEnchantments → Spellbook (E.5)
- WieldObject / InventoryPutObjInContainer → ItemRepository (F.2)
This is the piece that makes the dispatcher go from "thing that routes
opcodes" to "thing that populates state the UI can redraw from". Before
this, every handler had to be wired at each call site; now one call
at startup (or per-reconnect) does the whole map.
Project graph: added AcDream.Core.Net → AcDream.Core ProjectReference
so the wiring can see both the dispatcher (Net) and the state classes
(Core). Net's own tests already pull in Core indirectly, so test scope
is unchanged.
Tests (6 new, in Core.Net.Tests): verify round-trip via the actual
dispatcher. Build envelope → dispatch → assert the correct Core state
change. Covers ChannelBroadcast, UpdateHealth, MagicUpdateSpell,
WieldObject, PopupString, MagicPurgeEnchantments.
Build green, 602 tests pass (up from 596).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the client-side combat loop: send attacks, receive server's
damage broadcasts, maintain per-entity health state for HP bars +
damage floaters. All atop Phase F.1's GameEvent dispatcher.
Wire layer:
- AttackTargetRequest (0x0008 C→S, inside 0xF7B1): targetGuid +
powerLevel + accuracyLevel + attackHeight. 28-byte body.
- GameEvents parsers for all combat notifications from r08 §4:
- VictimNotification (0x01AC) — you got hit, full details
- KillerNotification (0x01AD) — you killed X
- AttackerNotification (0x01B1) — you hit X for Y (damage%)
- DefenderNotification (0x01B2) — X hit you
- EvasionAttackerNotification (0x01B3) — X evaded
- EvasionDefenderNotification (0x01B4) — you evaded X
- AttackDone (0x01A7) — attack sequence completed
Core layer:
- CombatState: per-entity health-percent cache + typed events
(HealthChanged, DamageTaken, DamageDealtAccepted, EvadedIncoming,
MissedOutgoing, AttackDone). Each event carries enough detail for
the UI to render damage floaters, HP bars, and a combat log panel.
Server is authoritative; client only mirrors state.
The server computes damage (armor, resist, crit, hit-chance); the
client only displays results. Predictive UI like "estimated damage
at 0.75 power" still works via the existing CombatMath helper class
that was in the scaffold (r02 §5 formulas).
Tests (13 new):
- AttackTargetRequest byte-exact wire encoding
- VictimNotification / AttackerNotification / EvasionAttacker /
AttackDone round-trip parse.
- CombatState: UpdateHealth caches + fires, Victim fires DamageTaken,
Attacker fires DamageDealt, Evasion routes to right event, AttackDone
carries sequence+error, Clear resets cache.
Build green, 544 tests pass (up from 532).
Ref: r02 §7 (wire formats), r08 §4 (event payloads), ACE
GameEvent*Notification.cs families.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the inbound GameEvent routing layer — the single biggest
network-protocol gap per r08 (94 sub-opcodes, zero handled before).
WorldSession now detects 0xF7B0, parses the 16-byte header (guid +
gameEventSequence + eventType), and forwards to a pluggable
GameEventDispatcher.
Added:
- GameEventEnvelope record + TryParse with layout from
ACE GameEventMessage.cs.
- GameEventType enum: all 94 S→C sub-opcodes from
ACE.Server.Network.GameEvent.GameEventType, named per ACE conventions.
- GameEventDispatcher: handler registry + unhandled-counts bag for
diagnostics ("which server events are firing that we don't parse?").
Handlers invoked synchronously on the decode thread; thrown exceptions
are swallowed + logged to stderr so one bad handler can't take down
the packet loop.
- GameEvents parsers: ChannelBroadcast, Tell, TransientMessage,
PopupString, WeenieError (+ WithString), UpdateHealth, PingResponse,
MagicUpdateSpell. Each returns a typed record or null on malformed
payload. String16L helper matches the existing CharacterList pattern
(u16 length + ASCII bytes + 4-byte pad).
- WorldSession.GameEvents property exposing the dispatcher so
GameWindow / UI / chat can register handlers at startup.
Wired into WorldSession.ProcessDatagram: new `else if (op ==
GameEventEnvelope.Opcode)` branch with TryParse + Dispatch.
Tests (13 new):
- Envelope: valid round-trip, wrong outer opcode, too-short body.
- Dispatcher: handler invoked, unhandled count, exception isolation,
unregister + rollover to unhandled.
- Event parsers: ChannelBroadcast, Tell, UpdateHealth, WeenieError,
Transient, MagicUpdateSpell.
Total: 521 tests pass (up from 508).
With this dispatcher in place, Phase F.2 (items + appraise), F.3 (combat
+ damage), F.4 (spell cast state machine), chat UI, allegiance, quest
tracker — all of which depend on GameEvent handling — are unblocked.
Ref: r08 §4 (GameEvent sub-opcode table), §2 (envelope wire shape).
Ref: ACE GameEventMessage.cs / GameEventType.cs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three separate fixes landed today, each addressing a specific bug the
user observed during live play:
1. NPC clothing changes by camera angle (InstancedMeshRenderer)
- Group key was (GfxObjId) only, so every humanoid NPC using the
same body mesh piled into one instance group; only the first
instance's texture was used for the entire DrawInstanced batch,
so which NPC's palette "won" changed as frustum culling and
iteration order shuffled entries.
- Now keyed by (GfxObjId, PaletteHash ^ SurfaceOverridesHash)
so only compatible instances batch; each unique appearance gets
its own draw call. Perf hit is small (humanoid NPCs each emit
one more draw call); visually every NPC is now stable.
2. GpuWorldState dedup on respawn
- Server re-sends CreateObject for the same guid on visibility
refresh / landblock crossing / appearance update. AppendLiveEntity
was blindly appending each time, so GpuWorldState accumulated
multiple copies of the same entity, each with its own
PaletteOverride / MeshRefs. That alone wasn't the clothing bug
(that was #1) but it would have caused other overlap problems
downstream.
- Added RemoveEntityByServerGuid + WorldGameState.RemoveById;
OnLiveEntitySpawnedLocked calls both before creating the new
entity so respawns replace cleanly.
3. Motion wire format — run animation sync with retail observers
- ACE's MovementData constructor only computes interpState.ForwardSpeed
on the WalkForward/WalkBackwards branch; every other ForwardCommand
falls into `else` and passes through WITHOUT speed set, giving
observers speed=0. Sending RunForward directly meant retail
clients saw us "run in place" while position drifted forward.
- Wire: always WalkForward + HoldKey.Run for running. ACE
auto-upgrades to RunForward with creature.GetRunRate() for
broadcast — correct command + correct speed at observers.
- Added per-axis FORWARD_HOLD_KEY / SIDE_STEP_HOLD_KEY /
TURN_HOLD_KEY so every active axis carries HoldKey.Run when
running (matches holtburger's build_motion_state_raw_motion_state).
- Added LocalAnimationCommand to MovementResult so our own
client still plays the RunForward cycle locally while the wire
stays WalkForward. Wire vs. local animation command are now
decoupled.
- Walk-backward wire command changed from WalkForward@-0.65 to
WalkBackward@1.0 (holtburger pattern).
- Strafe speed changed from 0.5 to 1.0 on wire AND local physics
(matches retail sidestep pace).
4. Jump height default + env-var tuning
- Default jumpSkill bumped from 100 → 200 (jump ≈ 3m at full
charge, closer to retail feel for a mid-level character).
- ACDREAM_RUN_SKILL and ACDREAM_JUMP_SKILL env vars now override
the defaults so the user can tune per-character until we parse
PlayerDescription and plumb real skill values through.
5. JustLanded signal on MovementResult
- Tracks airborne→grounded transition so future animation code
can fire the landing cycle when we land. Just a bool flag for
now — no consumer yet (the proper action-queue path will use it).
Not in this commit: jump animation itself. An earlier attempt to
SetCycle(Jump=0x2500003b) fed an Action-type motion into the SubState
cycle resolver, which produced a "torso" mis-render. Reverted. The
proper fix is porting the retail motion action-queue semantics into
AnimationSequencer — see docs/research/deepdives/r03-motion-animation.md
for the spec. That's the next session's work.
470 tests pass, build clean.
Build and send GameAction(Jump) with extent + world-space launch
velocity + sequence counters. Wire format from holtburger
JumpActionData::pack. Server can now validate and replicate jumps
to nearby clients.
Also compute RunRate locally via PlayerWeenie.InqRunRate when
running (server doesn't echo UpdateMotion ForwardSpeed to sender).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Parse ForwardSpeed from UpdateMotion (0xF74C) InterpretedMotionState.
Feed server-echoed RunRate into the player's MotionInterpreter so
get_state_velocity produces the correct speed. Previously hardcoded
at 1.0 (4.0 m/s), now matches character's Run skill.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sprint 1a of the audit remediation plan.
Extracts the 4 movement sequence counters from inbound server messages
and echoes them in outbound MoveToState + AutonomousPosition instead
of hardcoded zeros:
- instanceSequence (slot 8 in CreateObject PhysicsData timestamps)
- teleportSequence (slot 4, also from PlayerTeleport 0xF751)
- serverControlSequence (slot 5)
- forcePositionSequence (slot 6, also from UpdatePosition 0xF748)
Source: holtburger player/types.rs:237-245, mutations.rs:182-706.
The server uses these to detect stale/reordered movement packets.
Previously all zeros → server couldn't distinguish epoch boundaries.
Changes:
- CreateObject.Parsed: +4 sequence fields extracted from timestamps
- UpdatePosition.Parsed: +3 sequence fields from trailing u16s
- WorldSession: tracks 4 counters, updates from CreateObject/
UpdatePosition/PlayerTeleport for the player's own GUID
- GameWindow: passes tracked values to MoveToState.Build and
AutonomousPosition.Build
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PlayerTeleport (0xF751) is a standalone GameMessage (u16 sequence,
align-4). When received, WorldSession fires TeleportStarted(uint sequence).
GameWindow subscribes: OnTeleportStarted sets PlayerMovementController.State
= PortalSpace, freezing all WASD/physics input. OnLivePositionUpdated
detects arrival (different landblock or >100 unit jump on our character guid),
recenters the streaming origin, resolves physics for ground Z, snaps the
player entity + controller, returns State to InWorld, and sends
GameActionLoginComplete directly (matching holtburger's PlayerTeleport
handler: send_login_complete on every portal transition).
PlayerMovementController gains PlayerState enum + early-return guard: if
State == PortalSpace, Update() returns a zero-movement result immediately
so no MoveToState / AutonomousPosition messages are emitted during transit.
WorldSession gains ResetLoginComplete() for callers that need to re-arm
the latch (documented; not called by the teleport path since we send
LoginComplete directly rather than through the PlayerCreate latch).
Opcode source: holtburger/crates/holtburger-protocol/src/opcodes.rs:84
Wire layout: holtburger/crates/.../movement/messages/teleport.rs
Build: 0 errors. Tests: 283 passed, 0 failed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Outbound GameAction message builders for player movement:
- MoveToState (0xF61C): sent on motion state changes (start/stop
walking, turn, speed change). Carries RawMotionState (flag-driven
variable fields) + WorldPosition + sequence numbers.
- AutonomousPosition (0xF753): periodic position heartbeat sent
every ~200ms while moving. No RawMotionState — just WorldPosition
+ sequences + contact byte.
Both follow the GameAction envelope pattern (0xF7B1 + sequence +
action type) established by GameActionLoginComplete. Wire format
ported from references/holtburger movement protocol — field order
and alignment match exactly (contact byte + pad_to_4).
Also:
- Adds WriteFloat to PacketWriter (needed by both builders)
- Adds SendGameAction + NextGameActionSequence to WorldSession
(public wrappers for PlayerMovementController in Task 2)
11 new tests, 265 total, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Moves the UDP receive onto a dedicated daemon thread that
continuously pulls raw datagrams from the kernel buffer and posts
them to a Channel<byte[]>. Tick() on the render thread drains the
channel instead of calling _net.TryReceive() directly. All decode,
fragment assembly, ISAAC crypto, event dispatch, and ack-sending
remain on the render thread — this is the minimal change that
prevents packet drops during render-thread stalls without the
complexity of moving decode/dispatch off-thread.
The net thread starts at the end of EnterWorld() after the handshake
is complete — during Connect() and EnterWorld(), PumpOnce() still
reads directly from the socket (the net thread isn't running yet).
Dispose() cancels the thread via CancellationToken, joins with a
2-second timeout, then disposes the socket.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When closing the acdream window, WorldSession.Dispose now sends
CharacterLogOff (game message 0xF653, no payload) followed by a bare
DISCONNECT control packet (header flag 0x8000) before closing the
UDP socket. This tells ACE to release the character lock immediately
instead of waiting for the ~60s network timeout — which was blocking
rapid iteration during testing since acdream now does a proper login
(Phase 4.8-4.10) and ACE holds the character in-world.
Pattern from references/holtburger/.../client/commands.rs lines
879-892 (Quit handler). Best-effort: if the socket is already dead,
the exception is eaten and Dispose finishes cleanup normally.
220 tests still green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User reported that even with the Phase 4.9 ack pump, acdream's
character still rendered to other clients as the purple loading haze.
Spent another round in holtburger's references and found two more
gaps in the post-EnterWorld handshake:
1. Server sends DddInterrogation (game opcode 0xF7E5) and waits for
the client to acknowledge dat-list versions. We never replied.
Build the canonical empty response (12 bytes: opcode + language=1
+ count=0 lists) and ship it as soon as DddInterrogation arrives.
2. LoginComplete was being sent immediately after CharacterEnterWorld
in Phase 4.8, which is too early — the server hasn't finished
creating the player object yet so it ignores LoginComplete and
the player stays in transition state. The correct trigger is the
server's PlayerCreate (0xF746) game message for our character;
that's when holtburger fires send_login_complete (see references/
holtburger/.../client/messages.rs::PlayerCreate handler).
Wired both into ProcessDatagram. Removed the unconditional
LoginComplete from the EnterWorld flow. Added a _loginCompleteSent
latch so re-PlayerCreate (e.g., across portal teleports) doesn't
re-fire LoginComplete during the same session.
Reference repo cited per the new CLAUDE.md guidance — holtburger is
the authoritative client-behavior reference. Should have looked there
sooner; this would have saved the Phase 4.8 false fix.
220 tests still green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause of the still-purple-haze symptom AND the ACE-side
"Network Timeout" drop after ~60s. acdream was never sending
acknowledgement packets back to the server, so the server's
reliability layer saw a one-way stream and eventually dropped the
session. During the 60s window the player rendered to other clients
as the stationary purple loading haze (AC's "this client is in
portal-space transition" indicator).
Pattern ported from
references/holtburger/crates/holtburger-session/src/session/
{send.rs::send_ack, receive.rs::finalize_ordered_server_packet}.
The proper holtburger pattern is per-packet acks, NOT a periodic
heartbeat: every received server packet with sequence > 0 and no
ACK_SEQUENCE flag of its own gets a bare control packet sent back
with:
PacketHeader {
Flags = ACK_SEQUENCE (0x4000),
Sequence = current_client_sequence (= last issued, no increment),
Id = session client id,
}
Body = u32 little-endian server sequence being acked
Acks are cleartext control packets (no EncryptedChecksum) and
re-use the most recently issued client sequence rather than
consuming a new one — they aren't part of the reliable stream the
server tracks for retransmits.
Wired into ProcessDatagram so both Tick (post-InWorld) and PumpOnce
(during Connect/EnterWorld) trigger acks on every received non-ack
server packet.
Also (per user request) upgrades the CLAUDE.md description of the
holtburger reference repo from "Rust AC client crate" to "almost-
complete Rust TUI AC client — the most authoritative reference for
client-side behavior in the project, look here FIRST for anything
WorldSession or message-builder related." This was the third time
in two days I would have saved hours by checking holtburger first
instead of guessing at the protocol from ACE alone.
220 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User reported that when they observed acdream's character through a
second AC client running on a different account, the character
rendered as a stationary purple haze (AC's "loading screen / portal
space" indicator) instead of a normal avatar. The character was
"in-world enough" to receive the CreateObject stream but never
"in-world enough" for the server to flip its first-enter-world flag,
push initial property updates / equipment overrides, or show the
character to other clients in the area.
Root cause: WorldSession.EnterWorld stopped after sending
CharacterEnterWorld (0xF657). The handshake is supposed to continue
with one more message — a GameAction(LoginComplete) — that ACE's
GameActionLoginComplete handler interprets as "client has exited
portal space, mark FirstEnterWorldDone, push property updates,
make the character visible to others."
Wire layout (confirmed via
references/ACE/Source/ACE.Server/Network/GameAction/GameActionPacket.cs
and .../Actions/GameActionLoginComplete.cs):
u32 game-message opcode = 0xF7B1 (GameAction)
u32 sequence = 0 (ACE ignores; TODO comment in source)
u32 GameActionType opc = 0x000000A1 (LoginComplete)
Send happens immediately after CharacterEnterWorld and just before
flipping the WorldSession state to InWorld. acdream has no portal-
space transition animation, so we can claim "loading complete" the
moment we've sent the EnterWorld message — the dat-side world is
already loaded by then.
1 new test (97 Core.Net total). 220 tests green overall.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Companion to the Phase 6.6 UpdateMotion parser. Without this, every
server-spawned entity stays frozen at its CreateObject origin forever
— NPCs don't patrol, creatures don't hunt, other players don't walk
past. UpdatePosition is the per-entity position delta the server sends
on every movement tick.
The wire format is straightforward but fiddly:
u32 opcode | u32 guid | u32 flags | u32 cellId | 3xf32 pos
(0..4) conditional f32 rotation components, present iff the
corresponding OrientationHasNo* flag is CLEAR
optional 3xf32 velocity iff HasVelocity
optional u32 placementId iff HasPlacementID
four u16 sequence numbers (consumed but not used)
Layout ported from references/ACE/Source/ACE.Server/Network/Structure/
PositionPack.cs::Write and ACE.Entity/Enum/PositionFlags.cs.
WorldSession dispatches PositionUpdated(guid, position, velocity) on
a successful parse. GameWindow wiring (guid → WorldEntity lookup and
transform swap) is deferred to the same follow-up commit that lands
Phase 6.6 wiring, after the in-flight Phase 9.1 translucent-pass work
merges so we don't step on GameWindow.cs edits.
96 Core.Net tests (was 89, +7 for UpdatePosition coverage).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server sends UpdateMotion whenever an entity's motion state changes:
NPCs starting a walk cycle, creatures switching to a combat stance,
doors opening, a player waving, etc. Phase 6.1-6.4 already handles
rendering different (stance, forward-command) pairs for the INITIAL
CreateObject, but without this message NPCs freeze in whatever pose
they spawned with and never transition to walking/fighting.
Added UpdateMotion.TryParse with the same ServerMotionState the
CreateObject path uses, reached via a slightly different outer
layout (guid + instance seq + header'd MovementData; the MovementData
starts with the 8-byte sequence/autonomous header this time rather
than being preceded by a length field). Only the (stance, forward-
command) pair is extracted — same subset CreateObject grabs.
WorldSession dispatches MotionUpdated(guid, state) when a 0xF74C
body parses successfully. The App-side wiring (guid→entity lookup
and AnimatedEntity cycle swap) is intentionally deferred to a
separate commit because it touches GameWindow which is currently
being edited by the Phase 9.1 translucent-pass work.
89 Core.Net tests (was 83, +6 for UpdateMotion coverage).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Foundry's drudge statue Setup (0x020007DD) has DefaultMotionTable=0,
so MotionResolver returned null and the renderer fell back to
PlacementFrames[Default] — an upright pose, which is wrong. The retail
crouched/aggressive pose comes from a per-instance motion table the
server attaches via PhysicsDescriptionFlag.MTable (confirmed live as
0x090000DA for the statue).
CreateObject.TryParse was already walking the MTable field but
discarding the value. Now it captures it as Parsed.MotionTableId and
WorldSession.EntitySpawn forwards it. GameWindow passes it as the
motionTableIdOverride to MotionResolver.GetIdleFrame, so the cycle
lookup uses the server-supplied table when the dat-side default is
empty. With this in place the drudge resolves a real cycle and
renders in the correct crouched pose.
Trimmed the heavy STATUE motion-table dump diagnostics now that the
mechanism is verified; left a one-line summary so future regressions
remain debuggable. 160 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CreateObject's MovementData was being skipped past, so the renderer
always fell back to the MotionTable's default style/substate. That's
correct for most NPCs and characters but wrong for entities the
server explicitly puts into a non-default stance — most visibly the
Foundry's Nullified Statue of a Drudge, which the server sends with
a combat stance + Crouch ForwardCommand override and which therefore
rendered as an upright drudge instead of the aggressive crouched
statue you see on the retail client.
CreateObject.TryParse now extracts ServerMotionState (Stance +
optional ForwardCommand) from the inner MovementData. The header=false
layout was confirmed via ACE/.../WorldObject_Networking.cs:326 plus
MovementData.cs::Write and InterpretedMotionState.cs::Write. Only the
two fields the resolver needs are read; remaining InterpretedMotionState
bytes are skipped via the outer length so we don't have to handle
alignment of fields we don't care about.
MotionResolver.GetIdleFrame now takes optional stanceOverride and
commandOverride. Resolution priority is server-stance+command →
server-stance + style-default substate → MotionTable default. If the
composed cycle key doesn't resolve we fall back to the table default
rather than returning null, so a partial server override never makes
the entity worse than Phase 6.1.
160 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Nullified Statue of a Drudge renders correctly in color/shape
after Phase 5b's SubPalette fix, but the user reported it's rendering
at base drudge size when it should be larger. AC statues use the
PhysicsDescriptionFlag.ObjScale field to scale the base mesh; my
parser was consuming-and-skipping those 4 bytes.
Changes:
- CreateObject.TryParse: extract the u32 float from the ObjScale
field instead of advancing past it. Declaration moved to the top
of the method alongside other accumulators so the PartialResult
local function at the bottom can reference it for the truncation
fallback path. Same structural change for position and setupTableId
since PartialResult already needed them too.
- CreateObject.Parsed gains ObjScale (float?).
- WorldSession.EntitySpawn gains ObjScale; propagated through the
fire site in ProcessDatagram.
- GameWindow.OnLiveEntitySpawned bakes a scale matrix into every
MeshRef's PartTransform when ObjScale != 1.0, following the same
pattern the offline scenery hydration already uses. No change to
WorldEntity or StaticMeshRenderer — the scale is absorbed into the
per-part transform the renderer already multiplies through.
Tests: 77 core + 83 net = 160, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>