Commit graph

27 commits

Author SHA1 Message Date
Erik
25b9616703 feat(combat): Phase L.1c add outbound combat actions 2026-04-28 10:57:12 +02:00
Erik
b609b5ea6e feat(net): handle 0xF74E VectorUpdate so remote players' jumps render
Remote-player jumps were silently dropped — we never parsed the
VectorUpdate broadcast that carries the jump launch velocity, so
the remote body's Z velocity stayed at 0 and the jump animation
showed without any vertical motion.

ACE Player.cs:954 enqueues GameMessageVectorUpdate (opcode 0xF74E)
on every jump in addition to the bracketing UpdateMotion. Wire
layout (GameMessageVectorUpdate.cs):

  u32 opcode (= 0xF74E)
  u32 objectGuid
  3xf32 velocity (world-space, post-rotation)
  3xf32 omega
  u16 instanceSequence
  u16 vectorSequence

This commit:

1. Adds VectorUpdate.TryParse + VectorUpdated session event.
2. WorldSession.ProcessDatagram dispatches 0xF74E.
3. GameWindow subscribes via OnLiveVectorUpdated:
   - Sets remote PhysicsBody.Velocity from the wire vector.
   - When velocity.Z > 0.5 m/s, marks the remote as Airborne,
     clears Contact + OnWalkable bits, and enables the Gravity
     state flag — so calc_acceleration returns (0, 0, -9.8) and
     UpdatePhysicsInternal produces a parabolic arc.
4. The per-tick remote update (TickAnimations remote-physics
   block) now SKIPS the "force OnWalkable + apply_current_movement"
   step when Airborne. Otherwise that path stomps the +Z velocity
   each frame — same shape as the bug the local jump hit before
   K-fix7.
5. ResolveWithTransition for remotes now passes
   isOnGround: !rm.Airborne. Mirrors K-fix7's local-player gate —
   airborne resolves must NOT pre-seed the ContactPlane,
   otherwise AdjustOffset's snap-to-plane branch zeroes the
   upward offset.
6. UpdatePosition handler clears the airborne flag and restores
   ground-contact bits, so the server's authoritative re-grounding
   ends the arc cleanly at the new ground location.

ACDREAM_DUMP_MOTION=1 logs each VectorUpdate as
"VU guid=0x... vel=(...) airborne=...".

Tests stay 1222 green. Live verification pending — watch a remote
character jump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:38:32 +02:00
Erik
ca968fc766 feat(net+chat): #19 TurbineChat (0xF7DE) codec + ChatChannelInfo + SetTurbineChatChannels parser
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>
2026-04-25 19:44:56 +02:00
Erik
8e6e5a0b61 feat(ui+net): #16 LiveCommandBus + WorldSession.Send{Talk,Tell,Channel} + SendChatCmd wiring
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>
2026-04-25 19:27:22 +02:00
Erik
ff5ed9ec0b feat(net): #18 holtburger inbound chat parity - EmoteText, SoulEmote, ServerMessage, PlayerKilled, WeenieError + Windows-1252 codec
Five sub-changes:

1. Windows-1252 codec switch (global). Every Encoding.ASCII call site
   in src/AcDream.Core.Net/Messages/ -> Encoding.GetEncoding(1252).
   Touched HearSpeech, ChatRequests, GameEvents, AppraiseInfoParser,
   CharacterList, CreateObject, PlayerDescriptionParser, SocialActions.
   New Encodings.cs module-init registers CodePagesEncodingProvider
   (System.Text.Encoding.CodePages ships with .NET 10 SDK but isn't
   auto-registered). Matches retail + holtburger; accented names
   no longer round-trip-broken.

2. New parsers (opcodes confirmed against holtburger opcodes.rs):
   - EmoteText (0x01E0)     { u32 senderGuid, string16 senderName, string16 text }
   - SoulEmote (0x01E2)     same wire layout as EmoteText
   - ServerMessage (0xF7E0) { string16 message, u32 chatType }
   - PlayerKilled (0x019E)  { string16 deathMessage, u32 victimGuid, u32 killerGuid }
   Shared StringReader.cs has the CP1252 String16L primitive.

3. WorldSession dispatch. ProcessDatagram adds branches for the four
   new top-level opcodes + fires session-level events (EmoteHeard,
   SoulEmoteHeard, ServerMessageReceived, PlayerKilledReceived).
   0x0295 SetTurbineChatChannels stubbed with TODO for parallel I.6.

4. GameEventWiring routes WeenieError + WeenieErrorWithString
   (parsers existed but were unrouted) -> chat.OnWeenieError.

5. ChatLog adapters: Emote / SoulEmote ChatKind values, OnEmote,
   OnSoulEmote, OnPlayerKilled, OnWeenieError. OnLocalSpeech now
   substitutes empty sender -> "You" per holtburger client/messages.rs.
   ChatVM.FormatEntry handles new kinds (asterisk + sender + text).

22 new tests covering parser round-trips + reject-bad-opcode +
ChatLog adapter coverage + Win-1252 round-trip with non-ASCII chars.
Solution total: 881 green (210->225 in Core.Net.Tests, 606->613 in Core.Tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:06:01 +02:00
Erik
7da2a027d4 feat(player): #5 PlayerDescription parser — Stam/Mana via attribute block
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>
2026-04-25 16:42:24 +02:00
Erik
e4cf3a9b6b weather(phase-5d): AdminEnvirons packet handler + thunder sound dispatch
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>
2026-04-24 11:27:13 +02:00
Erik
2e9a836f08 weather(phase-6bc): wire PlayScript packet + script runner into frame loop
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>
2026-04-24 11:24:30 +02:00
Erik
cd89e9a498 feat(net+ui): Phase G.1 — server time sync + debug controls
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>
2026-04-19 10:51:03 +02:00
Erik
404cab55ba feat(chat): Phase H.1 Talk/Tell/ChatChannel + HearSpeech + ChatLog
Completes the chat-wire layer end-to-end: outbound Talk (/say), Tell
(/tell), ChatChannel, + inbound HearSpeech (0x02BB) / HearRangedSpeech
(0x02BC) routed into a unified ChatLog that also consumes the already-
parsed GameEvent ChannelBroadcast / Tell / TransientMessage / Popup.

Wire layer (AcDream.Core.Net/Messages):
- ChatRequests.BuildTalk (0x0015, inside 0xF7B1): gameActionSequence
  + string16L message. PackString16L helper with 4-byte pad.
- ChatRequests.BuildTell (0x005D): targetName + message, each
  string16L with its own padding.
- ChatRequests.BuildChatChannel (0x0147): channelId + message.
- HearSpeech.TryParse handles BOTH 0x02BB local AND 0x02BC ranged —
  single parser with IsRanged flag in the returned record. Standalone
  GameMessage (NOT wrapped in 0xF7B0).

WorldSession integration:
- ProcessDatagram branch for HearSpeech.LocalOpcode /
  HearSpeech.RangedOpcode; fires new SpeechHeard event.
- Places the new branch before the 0xF7B0 GameEvent branch so ordering
  stays stable.

Core layer (AcDream.Core/Chat):
- ChatEntry record: (Kind, Sender, Text, SenderGuid, ChannelId, Received).
- ChatKind enum: LocalSpeech, RangedSpeech, Channel, Tell, System, Popup.
- ChatLog: ring-buffer (default 500) of entries; adapters for every
  inbound source (OnLocalSpeech, OnChannelBroadcast, OnTellReceived,
  OnSystemMessage, OnPopup) plus OnSelfSent for echoing outbound.
  Fires EntryAppended so UI panel can scroll / highlight.

Tests (15 new):
- ChatRequests: Talk / Tell / ChatChannel byte-exact encoding (including
  string16L padding edge cases).
- HearSpeech: local + ranged round-trip, wrong-opcode returns null.
- ChatLog: local / ranged / channel / tell / system / self echo,
  ring-buffer drops oldest, Clear empties.

Build green, 570 tests pass (up from 555).

With the chat wire layer in place, Phase H.1's "chat window panel"
(UI slice 05) is purely a UI task: instantiate ChatLog, bind to
EntryAppended, feed rows into the retail-UI widget toolkit. No more
protocol gaps.

Ref: r08 §3 (opcodes 0x0015, 0x005D, 0x0147), §2 (0x02BB, 0x02BC).
Ref: ACE GameMessageHearSpeech.cs + GameActionChannelBroadcast.cs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:03:45 +02:00
Erik
d86fd08011 feat(net): Phase F.1 GameEvent (0xF7B0) envelope dispatcher
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>
2026-04-18 16:52:46 +02:00
Erik
11974c2099 feat(net): track + echo movement sequence counters
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>
2026-04-13 13:45:39 +02:00
Erik
ae06f9c0ff feat(net+app): Phase B.3 — portal-space state machine for teleports
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>
2026-04-12 18:32:41 +02:00
Erik
fe1c949775 feat(net): Phase B.2 — MoveToState + AutonomousPosition message builders
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>
2026-04-12 14:28:35 +02:00
Erik
ae43531866 feat(net): Phase A.3 — background net receive thread
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>
2026-04-12 09:20:56 +02:00
Erik
fb3c8ebdaa feat(net): graceful CharacterLogOff + DISCONNECT on session Dispose
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>
2026-04-12 08:38:15 +02:00
Erik
8c14e0207c feat(net): Phase 4.10 — DddInterrogationResponse + correct LoginComplete trigger
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>
2026-04-11 23:48:37 +02:00
Erik
af381ac6fb feat(net): Phase 4.9 — send ACK_SEQUENCE for every received server packet
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>
2026-04-11 23:42:41 +02:00
Erik
8744bd6179 feat(net): Phase 4.8 — send GameAction.LoginComplete after EnterWorld
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>
2026-04-11 23:36:19 +02:00
Erik
333a7c197a feat(net): Phase 6.7 — parse UpdatePosition (0xF748) into PositionUpdated event
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>
2026-04-11 20:37:32 +02:00
Erik
a71db90310 feat(net): Phase 6.6 — parse UpdateMotion (0xF74C) into MotionUpdated event
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>
2026-04-11 20:33:26 +02:00
Erik
b96167e066 feat(net+app): Phase 6.3 — extract server MotionTableId and use as resolver override
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>
2026-04-11 19:03:00 +02:00
Erik
120e801ecf feat(net+core): Phase 6.2 — honor server CurrentMotionState for idle pose
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>
2026-04-11 18:48:33 +02:00
Erik
f67f7851e6 feat(net+app): apply ObjScale from PhysicsData (Phase 5c)
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>
2026-04-11 18:01:14 +02:00
Erik
b69d776179 feat(net+app): TextureChanges applied via Surface→OrigTex resolution (Phase 5a)
Finishes the TextureChange half of ObjDesc. Characters' clothing now
renders with correct per-part textures (user-verified "looks good"
after previous "partial coverage" / "wrong clothes"). The Nullified
Statue still looks like a flesh-colored drudge because the statue's
color comes from SubPalettes (palette-indexed texture recoloring),
which is the remaining major Phase 5 piece.

The first attempt at TextureChange application was silently broken by
an ID-type mismatch: the server encodes OldTexture/NewTexture as
SurfaceTexture (0x05XXXXXX) ids, but my sub-meshes are keyed by
Surface (0x08XXXXXX) ids. The override dict was keyed by one type
and looked up by the other, so TryGetValue never hit and no override
actually applied.

Diagnosed via Phase 1 systematic debugging with resolve-level logging:

  live: spawn +Acdream texChanges=20
  live:   texChange part=0 old=0x05000BB0 new=0x0500025D
  ...
  live:   resolve part=0 surface=0x08000519 origTex=0x05000BB0 [MATCH]
  live:   resolve part=0 surface=0x0800051C origTex=0x05000CBE [MATCH]
  ... 10/10 lines [MATCH]

The [MATCH] lines proved the server's OldTexture IS reachable via a
Surface→OrigTextureId lookup, just needed keying by the right value.

Fix:
  - TextureCache.GetOrUploadWithOrigTextureOverride(surfaceId, origTexOverride):
    loads the base Surface dat for its color/flags/palette, but
    substitutes the override SurfaceTexture id in the decode chain.
    Caches under a (surfaceId, origTexOverride) composite key.
  - MeshRef.SurfaceOverrides is now Dictionary<uint, uint> keyed by
    Surface id, value = replacement OrigTextureId. Null means no
    overrides.
  - GameWindow.OnLiveEntitySpawned now does TWO passes when texture
    changes are present:
      1. Group the raw server changes by PartIndex into (oldOrigTex →
         newOrigTex) dicts
      2. For each affected part's post-animPartChange GfxObj, iterate
         its Surfaces list, resolve each Surface → OrigTextureId, and
         if that matches a raw change's oldOrigTex, write an entry
         Surface id → newOrigTex into the final override map
  - StaticMeshRenderer.Draw: when sub-mesh surface id has an override,
    call GetOrUploadWithOrigTextureOverride instead of GetOrUpload.

Verified live: +Acdream's clothing renders correctly, NPCs are
"much better" (characters previously naked are now dressed). Statue
has the full mechanical pipeline working (resolve diagnostic shows
2/2 Surfaces [MATCH] for the statue's override dict) but its visible
color comes from the separate SubPalette overlay that isn't wired yet.

Also added a statue-targeted diagnostic block that dumps its full
ObjDesc contents (texChanges + subPalettes + animPartChanges) by
name match, which is how I traced the Nullified Statue of a Drudge's
specific ObjDesc. Lives under `if (isStatue && ...)` so normal logins
aren't spammed.

Cross-referenced against two new references this session:
  * references/Chorizite.ACProtocol (cloned from github.com/Chorizite/
    Chorizite.ACProtocol.git on user's suggestion) — confirms the
    ObjDesc field order and PackedDword-of-known-type convention.
  * references/WorldBuilder/... (already in repo) — confirms the
    Surface→OrigTexture→SurfaceTexture→RenderSurface chain and the
    P8/INDEX16 palette decode path.

Tests: 77 core + 83 net = 160, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:22:23 +02:00
Erik
6ab24c9982 feat(net+app): AnimPartChanges + Name extraction — characters clothed,
statue identified (Phase 4.7h/i/j/k/l)

Makes three big improvements to the CreateObject decode path:

1. Extract AnimPartChanges from the ModelData section instead of
   skipping them. Each change is (PartIndex, NewModelId); the server
   uses these to replace base Setup parts with armor/clothing/statue
   meshes. The player character has ~34 of them on a normal login.

2. Flow AnimPartChanges through WorldSession.EntitySpawn into
   GameWindow.OnLiveEntitySpawned, which now patches the flattened
   Setup's part list BEFORE uploading GfxObjs. Patching is a simple
   "parts[change.PartIndex] = new MeshRef(change.NewModelId, oldTransform)"
   keeping the base Setup's placement transform but swapping the mesh.

3. Read the WeenieHeader Name (String16L) that follows the PhysicsData
   section. Required walking past every remaining physics flag (Parent,
   Children, ObjScale, Friction, Elasticity, Translucency, Velocity,
   Acceleration, Omega, DefaultScript, DefaultScriptIntensity) plus the
   9 sequence timestamps (2 bytes each) plus 4-byte alignment. The
   Name field is then the second thing in the WeenieHeader after
   u32 weenieFlags.

Critical bug fix in the same commit: ACE's WritePackedDwordOfKnownType
STRIPS the known-type high-byte prefix (e.g. 0x01000000 for GfxObj ids)
before writing the PackedDword. The first version of AnimPartChange
decoding called plain ReadPackedDword, so it got 0x0000XXXX instead of
0x0100XXXX and every GfxObj dat lookup silently failed — the drop
counter showed 19+ noMeshRef drops including +Acdream himself.

Added ReadPackedDwordOfKnownType that ORs the knownType bit back in
on read (with zero preserved as the "no value" sentinel). After the
fix, noMeshRef drops = 0 across a full login.

LIVE RUN after all three changes:

  live: spawn guid=0x5000000A name="+Acdream" setup=0x02000001
        pos=(58.5,156.2,66.0)@0xA9B40017 animParts=34
  live: spawn guid=0x7A9B4035 name="Holtburg" setup=0x020006EF
        pos=(94.6,156.0,66.0)@0xA9B4001F animParts=0
  live: spawn guid=0x7A9B4000 name="Door" setup=0x020019FF
        pos=(84.1,131.5,66.1)@0xA9B40100 animParts=0
  live: spawn guid=0x7A9B4001 name="Chest" setup=0x0200007C
        pos=(78.1,136.9,69.5)@0xA9B40105 animParts=0
  live: spawn guid=0x7A9B4036 name="Well" setup=0x02000180
        pos=(90.1,157.8,66.0)@0xA9B4001F animParts=0
  live: spawn guid=0x800005FD name="Wide Breeches" setup=0x02000210
        pos=no-pos animParts=1
  live: spawn guid=0x800005FC name="Smock" setup=0x020000D4
        pos=no-pos animParts=1
  live: spawn guid=0x800005FE name="Shoes" setup=0x020000DE
        pos=no-pos animParts=1
  live: spawn guid=0x80000697 name="Facility Hub Portal Gem"
        setup=0x02000921 pos=no-pos animParts=0
  live: spawn guid=0x7A9B404B name="Nullified Statue of a Drudge"
        setup=0x020007DD pos=(65.3,156.8,72.8)@0xA9B40017 animParts=1

  summary recv=60 hydrated=43 drops: noPos=17 noSetup=0
                                     setupMissing=0 noMesh=0

The statue's exact data is now known and the hydration path runs
without errors. The user's "look at the Name field in the CreateObject
body" insight turned this from an unbounded visual hunt into a targeted
grep of ~60 log lines.

Tests: 77 core + 83 net = 160 passing (offline suite unchanged).
Live handshake + enter-world tests still pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:48:13 +02:00
Erik
713bec256b feat(net+app): WorldSession class + GameWindow live-mode wiring (Phase 4.7e/f)
The end-to-end pipeline. acdream can now connect to a live ACE server,
complete the full handshake + character-select + enter-world flow, and
stream CreateObject messages straight into the existing IGameState and
static mesh renderer. Gated behind ACDREAM_LIVE=1 so the default
offline run path is untouched.

Added:
  - AcDream.Core.Net.WorldSession: high-level session type that owns a
    NetClient, drives the 3-leg handshake, parses CharacterList, sends
    CharacterEnterWorldRequest + CharacterEnterWorld, and converts the
    post-login fragment stream into C# events. State machine:
    Disconnected → Handshaking → InCharacterSelect → EnteringWorld →
    InWorld (or Failed). Public API:
      * Connect(user, pass)  — blocks until CharacterList received
      * EnterWorld(user, characterIndex) — blocks until ServerReady
      * Tick() — non-blocking, call per game-loop frame
      * event EntitySpawned
      * event StateChanged
      * Characters property (populated after Connect)

  - NetClient.TryReceive: non-blocking variant that returns immediately
    with null if the kernel buffer is empty. Enables draining packets
    per frame from the main thread without stalling.

  - GameWindow live-mode hookup:
      * AcDream.Core.Net project reference
      * TryStartLiveSession() called after dat hydration, gated behind
        ACDREAM_LIVE=1 + ACDREAM_TEST_USER/ACDREAM_TEST_PASS env vars
      * Subscribes EntitySpawned to OnLiveEntitySpawned
      * Calls Connect() then EnterWorld(0) synchronously on startup
      * OnLiveEntitySpawned hydrates mesh refs from the Setup dat
        (same SetupMesh.Flatten + GfxObjMesh.Build + StaticMesh.EnsureUploaded
        path used by scenery), publishes a WorldEntitySnapshot via
        _worldGameState.Add + _worldEvents.FireEntitySpawned, and
        appends to _entities so the next frame picks it up
      * OnUpdate calls _liveSession?.Tick() each frame
      * OnClosing disposes the session
      * Position translation: server sends (LandblockId, local XYZ +
        quaternion); we map landblock to world origin relative to the
        rendered 3x3 center, add local XYZ, translate AC's (W,X,Y,Z)
        quaternion wire order to System.Numerics.Quaternion (X,Y,Z,W)

LIVE RUN OUTPUT (ACDREAM_LIVE=1 against localhost ACE, testaccount):

  [dats loaded, 1133 static entities hydrated]
  live: connecting to 127.0.0.1:9000 as testaccount
  live: entering world as 0x5000000A +Acdream
  live: in world — CreateObject stream active (so far: 0 received, 0 hydrated)
  live: spawned guid=0x5000000A setup=0x02000001 world=(104.9,15.1,94.0)
  live: spawned guid=0x7A9B4013 setup=0x0200007C world=(135.7,9.9,97.0)
  live: spawned guid=0x7A9B4014 setup=0x0200007C world=(132.5,9.9,97.0)
  live: spawned guid=0x7A9B4015 setup=0x020019FF world=(132.6,17.1,94.1)
  live: spawned guid=0x7A9B4016 setup=0x020019FF world=(136.3,5.2,94.1)
  live: spawned guid=0x7A9B4017 setup=0x020019FF world=(104.1,31.0,94.1)
  live: spawned guid=0x7A9B4037 setup=0x02000975 world=(109.7,33.0,95.0)
  live: spawned guid=0x7A9B4018 setup=0x020019FF world=(110.9,31.0,94.1)
  live: spawned guid=0x7A9B4019 setup=0x020019FF world=(107.5,31.5,94.1)
  live: spawned guid=0x7A9B403B setup=0x02000B8E world=(150.5,17.9,94.0)
  live: (suppressing further spawn logs)

First line: +Acdream himself. setup=0x02000001 is ACE's default humanoid
player mesh. world coords match Holtburg (landblock 0xA9B4 local
space). Subsequent spawns are weenies at various setup ids — likely
the foundry statue, street lamps, drums, etc. The 0x7A9B4xxx GUID
pattern is ACE's convention: scenery-type (0x7) + landblock (0xA9B4) +
per-object index.

All spawns flow through the SAME SetupMesh/GfxObjMesh/StaticMeshRenderer
pipeline used by scenery and interiors today. The plugin system's
EntitySpawned event fires on every new entity, so plugins can see
them without any networking awareness.

Tests: 160 passing offline (77 core + 83 net). The live handshake and
enter-world tests are gated and still pass when ACDREAM_LIVE=1.

User visual verification is the final acceptance for Phase 4. Run
with ACDREAM_DAT_DIR + ACDREAM_LIVE=1 + ACDREAM_TEST_USER=testaccount
+ ACDREAM_TEST_PASS=testpassword and look for +Acdream's model + the
foundry statue standing on top of the Holtburg foundry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:25:41 +02:00