Commit graph

25 commits

Author SHA1 Message Date
Erik
e16f3315d2 feat(net): AppraiseInfoParser — full PropertyBundle deserializer
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>
2026-04-18 17:22:00 +02:00
Erik
d461279207 feat(char): character progression actions — Raise / Train / CombatMode
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>
2026-04-18 17:19:31 +02:00
Erik
68efb60b49 feat(interact): Phase B.4 Use / UseWithTarget / TeleToLifestone outbound
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>
2026-04-18 17:18:36 +02:00
Erik
62cf755e7d feat(allegiance): Phase H.2 AllegianceRequests + AllegianceTree model
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>
2026-04-18 17:17:45 +02:00
Erik
83e0e4f9ca feat(net): GameEventWiring — one-call glue from dispatcher to Core state
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>
2026-04-18 17:12:05 +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
c95aedcd4a feat(spells): Phase E.5 CastSpellRequest + Spellbook/enchantment state
Completes the client-side spell loop on top of Phase F.1. Player can
send cast requests; spellbook + active-enchantment state mirrors what
the server broadcasts.

Wire layer:
- CastSpellRequest (C→S, inside 0xF7B1 GameAction):
  - BuildUntargeted (0x0048): self-buffs, recalls, heal-self — 16 bytes.
  - BuildTargeted (0x004A): projectile attacks, target buffs/debuffs — 20 bytes.
- GameEvents parsers added:
  - 0x02C1 MagicUpdateSpell: spell-id → spellbook.
  - 0x01A8 MagicRemoveSpell.
  - 0x02C2 MagicUpdateEnchantment: spellId + layerId + duration + casterGuid
    (summary head; full stat-mod body deferred).
  - 0x02C3 MagicRemoveEnchantment: (layerId, spellId).
  - 0x02C7 MagicDispelEnchantment: same shape.

Core layer:
- Spellbook: learned-spell set + active-enchantment-by-layer dict
  with events (SpellLearned, SpellForgotten, EnchantmentAdded,
  EnchantmentRemoved). Duplicate learn is idempotent. Same-layer
  add refreshes duration. Purge fires per-record remove for UI
  cleanup.
- ActiveEnchantmentRecord: (SpellId, LayerId, Duration, CasterGuid).

Tests (10 new):
- CastSpellRequest untargeted (16 bytes) + targeted (20 bytes) wire encoding.
- GameEvents: MagicUpdateSpell, MagicUpdateEnchantment,
  MagicRemoveEnchantment round-trip.
- Spellbook: learn idempotent, forget, add/refresh enchantment,
  remove fires event, purge-all clears + fires per-record.

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

Ref: r01 §2 (wire casts), §3 (cast state machine), §5 (stacking rules).
Ref: r08 §3 opcodes 0x0048/0x004A, §4 opcodes 0x01A8/0x02C1-0x02C8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:00:32 +02:00
Erik
2e3f9d7a04 feat(combat): Phase E.4 AttackTargetRequest + combat notification pipeline
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>
2026-04-18 16:58:14 +02:00
Erik
2561f5599f feat(items): Phase F.2 ItemRepository + AppraiseRequest round-trip
Implements the item-state mirror + appraise round-trip infrastructure
on top of Phase F.1's GameEvent dispatcher.

Core layer (AcDream.Core/Items):
- ItemRepository: ConcurrentDictionary-backed live item state keyed by
  server ObjectId. Events: ItemAdded, ItemMoved, ItemRemoved,
  ItemPropertiesUpdated. MoveItem handles container / slot / equip
  location updates atomically and fires ItemMoved with old+new container
  ids. UpdateProperties merges a PropertyBundle patch (for appraise
  results) without clobbering existing untouched keys.

Wire layer (AcDream.Core.Net/Messages):
- AppraiseRequest (0x00C8 C→S, inside 0xF7B1 GameAction envelope):
  Build(sequence, targetGuid) → 16-byte body ready for SendGameAction.
- GameEvents.ParseIdentifyResponseHeader for 0x00C9 S→C — extracts
  (guid, appraiseFlags, success). Full PropertyBundle deserialization
  (the 10-flag bitfield-indexed tables) is a future pass; header alone
  is enough to route into the repository + surface "appraise complete"
  to UI.
- GameEvents.ParseWieldObject (0x0023) — server-driven equip.
- GameEvents.ParsePutObjInContainer (0x0022) — server-driven inventory
  move (item, container, placement).

Tests (11 new):
- ItemRepository: add/update fires correct event, move updates fields,
  missing-id returns false, remove, properties merge, clear.
- Wire: AppraiseRequest byte-exact encoding, IdentifyResponse header
  round-trip, WieldObject round-trip, PutObjInContainer round-trip.

Build green, 532 tests pass (up from 521).

Phase F.2 unblocks the Paperdoll + Inventory UI panels and the
"appraise on right-click" UX. Next pieces: PropertyBundle full
deserializer (AppraiseInfo 10-flag bitfield), outbound move/drop/
pickup actions.

Ref: r06 §1 (ItemType), §2 (EquipMask), §5 (appraise wire), §7 (pack
depth rules).
Ref: ACE GameEventIdentifyObjectResponse.cs for AppraiseInfo format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:55:36 +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
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
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
9e4313f3d3 feat(net): CreateObject body parser — GUID + Position + SetupId extracted (Phase 4.7d)
Decodes the CreateObject (0xF745) game message body far enough to hand
an entity off to acdream's existing IGameState/MeshRenderer pipeline.
Ported from ACE's WorldObject_Networking.cs (SerializeCreateObject,
SerializeModelData, SerializePhysicsData) and Position.cs.

Scope: the parser extracts exactly three fields —
  - GUID (u32 right after the opcode)
  - ServerPosition (landblockId + XYZ + rotation quaternion), if the
    Position bit is set in the PhysicsDescriptionFlag
  - SetupTableId (setup dat id for the visual mesh chain), if the
    CSetup bit is set

Everything else in a CreateObject body (weenie header, object description,
motion tables, palettes, texture overrides, animation frames, velocity,
acceleration, omega, scale, friction, elasticity, translucency,
default scripts, sequence timestamps, ...) is consumed-or-skipped with
just enough bytes to advance past the correct flag-gated sections.
The parser stops at the end of PhysicsData — we don't need weenie-header
fields for rendering placement.

Components parsed in order (all from ACE's serialize routines):
  1. Opcode u32 (must be 0xF745)
  2. u32 GUID
  3. ModelData header (byte 0x11 marker, byte subPaletteCount,
     byte textureChangeCount, byte animPartChangeCount), followed by
     PackedDword palette/subPalette fields, texture change records,
     anim part change records, aligned to 4 bytes at end
  4. u32 PhysicsDescriptionFlag
  5. u32 PhysicsState (skipped)
  6. Conditional Movement/AnimationFrame section
  7. Conditional Position section (LandblockId, X, Y, Z, RW, RX, RY, RZ)
  8. Conditional MTable/STable/PeTable u32 ids (all skipped)
  9. Conditional CSetup u32 (extracted as SetupTableId)

The PackedDword reader is a new helper: AC's variable-width uint format
where values ≤ 32767 encode as a u16, larger values use a marker bit in
the top of the first u16 and a continuation u16. Ported from
Extensions.WritePackedDword.

LIVE RUN AGAINST THE ACE SERVER (test account, Holtburg):

  step 4: CharacterList received account=testaccount count=2
    character: id=0x5000000A name=+Acdream
    character: id=0x50000008 name=+Wdw
  sent CharacterEnterWorldRequest
  step 6: CharacterEnterWorldServerReady received
  sent CharacterEnterWorld(guid=0x5000000A)
  step 8 summary: 83 GameMessages assembled, 68 CreateObject,
                  68 parsed, 52 w/position, 68 w/setup

  First 10 parsed CreateObjects:
    guid=0x5000000A lb=0xA9B40021 xyz=(104.89,15.05,94.01) setup=0x02000001
    guid=0x80000600 no position setup=0x02000181
    guid=0x800005FF no position setup=0x02000B77
    guid=0x80000603 no position setup=0x02000176
    guid=0x80000604 no position setup=0x02000D5C
    guid=0x80000694 no position setup=0x020005FF
    guid=0x80000697 no position setup=0x02000921
    guid=0x80000601 no position setup=0x02000179
    guid=0x80000605 no position setup=0x02000155
    guid=0x80000695 no position setup=0x020005FF

The first line is +Acdream himself — GUID matches what we picked from
CharacterList, landblock 0xA9B4 is Holtburg (the area we already render),
setup 0x02000001 is the default humanoid player mesh. The other 67 are
NPCs/weenies/scenery-weenies in the same area; the 16 without positions
are inventory items whose position is inherited from the parent.

ALL 68 CreateObjects parsed cleanly — no short reads, no format errors.
Phase 4.7d proves byte-level compatibility with ACE's outbound network
serialization format. The remaining Phase 4 work (WorldSession type +
GameWindow wiring) is glue code above a codec that now speaks the real
AC wire format.

Tests: 77 core + 83 net (+1 live test) = 161 passing, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:18:54 +02:00
Erik
94da385ff4 feat(net): acdream enters the world — CharacterList parsed + CharacterEnterWorld sent + 68 CreateObject received (Phase 4.7)
Drives the full post-handshake flow on a live ACE server. After the
3-way handshake completes, acdream:
  1. Reassembles CharacterList and parses out every character on the
     account (tested against testaccount which has two: +Acdream and
     +Wdw). Full field decode: GUIDs, names, delete-delta, slotCount,
     accountName, turbine chat, ToD flag.
  2. Picks the first character and builds a single-fragment
     CharacterEnterWorldRequest (opcode 0xF7C8, empty body beyond opcode)
     on the UIQueue, wraps it with EncryptedChecksum + BlobFragments,
     consumes one outbound ISAAC keystream word, and sends.
  3. Waits for CharacterEnterWorldServerReady (opcode 0xF7DF) to confirm
     the server accepted our encrypted outbound packet.
  4. Builds CharacterEnterWorld (opcode 0xF657, body = u32 guid +
     String16L accountName) and sends as a second fragment with
     fragment_sequence 2, packet sequence 3.
  5. Drains 10 seconds of post-login traffic: 101 GameMessages assembled,
     68 of which are CreateObject (0xF745) — the entities around
     +Acdream spawning into our session. Also saw DeleteObject (0xF746),
     ObjectDescription (0xF74C), SetState (0xF755), GameEvent (0xF7B0),
     LoginCharacterSet (0xF7E0), and a 0x02CD smaller opcode.

This is the Phase 4.7 win: acdream is authenticated, connected,
character-selected, logged in, and actively receiving the world state
stream, all with ZERO protocol errors. Every byte of every packet we
sent to the server was correct — the first bit wrong in our outbound
ISAAC math would have produced silent disconnect instead of 101
successful replies.

Added to AcDream.Core.Net:
  - Messages/CharacterList.cs: full parser for opcode 0xF658, ported
    from ACE's GameMessageCharacterList writer. Returns structured
    record with Characters[], SlotCount, AccountName, UseTurbineChat,
    HasThroneOfDestiny. Tested offline with hand-assembled bodies
    matching ACE's writer format.
  - Messages/CharacterEnterWorld.cs: outbound builders for
    CharacterEnterWorldRequest (0xF7C8, opcode-only) and
    CharacterEnterWorld (0xF657, opcode + guid + String16L account).
  - Messages/GameMessageFragment.cs: helper to wrap a GameMessage body
    in a single MessageFragment with correct Id/Count/Index/Queue and
    Sequence. Also a Serialize helper to turn a MessageFragment into
    packet-body bytes for PacketCodec.Encode. Throws on oversize
    (>448 byte) messages; multi-fragment outbound split is TBD.
  - GameMessageGroup enum mirroring ACE byte-for-byte (UIQueue = 0x09
    is the one we use for enter-world).

Fixed: FragmentAssembler was keying on MessageFragmentHeader.Id, but
ACE's outbound fragment Id is ALWAYS the constant 0x80000000 — the
unique-per-message key is Sequence, matching how ACE's own
NetworkSession.HandleFragment keys its partialFragments dict. Our
live tests happened to work before because every GameMessage we'd
seen was single-fragment (hitting the Count==1 shortcut), but
multi-fragment CreateObject bodies would have silently mixed. Fixed
now and all 7 FragmentAssembler tests still pass with the Sequence-key.

Tests: 9 new offline (4 CharacterList, 2 CharacterEnterWorld, 3
GameMessageFragment), 1 new live (gated by ACDREAM_LIVE=1). Total
77 core + 83 net = 160 passing.

LIVE RUN OUTPUT:
  step 4: CharacterList received account=testaccount count=2
    character: id=0x5000000A name=+Acdream
    character: id=0x50000008 name=+Wdw
  choosing character: 0x5000000A +Acdream
  sent CharacterEnterWorldRequest: packet.seq=2 frag.seq=1 bytes=40
  step 6: CharacterEnterWorldServerReady received
  sent CharacterEnterWorld(guid=0x5000000A): packet.seq=3 frag.seq=2 bytes=60
  step 8 summary: 101 GameMessages assembled, 68 CreateObject
  unique opcodes seen: 0xF7B0, 0xF7E0, 0xF746, 0xF745, 0x02CD,
                       0xF755, 0xF74C

Phase 4.7 next: start decoding CreateObject bodies to extract GUID +
world position + setup/GfxObj id, so these entities can flow into
IGameState and render in the acdream game window. The foundry statue
is waiting in one of those 68 spawns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:14:31 +02:00
Erik
0aea24c78e feat(net): extract GameMessage opcodes from live fragment stream (Phase 4.6f)
Reassembles the fragments arriving from the live handshake into full
game message bodies, reads the opcode from the first 4 bytes, and
identifies them by name. On the live wire we now see exactly the
sequence ACE sends right after HandleConnectResponse:

  GameMessage assembled: opcode=0xF7E5 (DDDInterrogation), body=28 bytes
  GameMessage assembled: opcode=0xF658 (CharacterList),    body=80 bytes
  GameMessage assembled: opcode=0xF7E1 (ServerName),       body=20 bytes

  summary: 5 packets received, 5 decoded OK, 0 checksum failures,
           3 GameMessages assembled

Every layer of the net stack is now proven live:
  * NetClient send/receive on both ports 9000 and 9001
  * PacketCodec.Encode building LoginRequest + ConnectResponse with
    correct unencrypted CRC
  * IsaacRandom byte-compatible with ACE's ISAAC (3 EncryptedChecksum
    packets decoded, zero mismatches)
  * PacketHeaderOptional parsing ConnectRequest, TimeSync, AckSequence
  * MessageFragment.TryParse walking a body tail of back-to-back
    fragments (the 152-byte packet had TWO messages: CharacterList
    and ServerName packed into one datagram)
  * FragmentAssembler reassembling by index

The CharacterList body has our test character +Acdream inside it but
we're not decoding its fields yet — that's Phase 4.7 where we actually
pick a character and send CharacterLogin to enter the game world.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:53:08 +02:00
Erik
a961d842d4 feat(net): full 3-way handshake + ISAAC-encrypted decode proven live (Phase 4.6e)
This is the Phase 4 protocol-compatibility proof. acdream's codec now
completes the full AC UDP handshake against a live ACE server and
successfully decodes three consecutive EncryptedChecksum game packets
— which means every layer of the codec is byte-compatible with ACE.

Changes:
  - NetClient: added Send(IPEndPoint, ReadOnlySpan<byte>) overload so
    one socket can talk to ACE's two listener ports (9000 for
    LoginRequest, 9001 for ConnectResponse and all subsequent traffic)
  - LiveHandshakeTests.Live_FullThreeWayHandshake_ReachesConnectedState:
    drives the full 3-leg handshake end-to-end. Protocol details that
    I got wrong on the first attempt and fixed after reading
    references/holtburger/crates/holtburger-session/src/session/auth.rs:
      * ConnectResponse header.Sequence = 1 (LoginRequest is seq 0)
      * ConnectResponse header.Id = 0 (NOT the clientId from
        ConnectRequest; that field is ACE's internal session index,
        separate from the packet header Id)
      * 200ms Thread.Sleep before sending ConnectResponse — holtburger
        calls this ACE_HANDSHAKE_RACE_DELAY_MS, empirically determined
        to avoid a server-side race where ACE is still finalizing the
        session when our ConnectResponse arrives
      * ConnectResponse goes to port 9001, not 9000 (ACE's second
        ConnectionListener, see Network/Managers/SocketManager.cs)

LIVE RUN OUTPUT:
  [live] step 1: sending 84-byte LoginRequest to 127.0.0.1:9000
  [live] step 2: got 52-byte datagram from 127.0.0.1:9000,
                 flags=ConnectRequest
         ConnectRequest cookie=0x458ABEE950D18BEE clientId=0x00000000
  [live] step 3: sleeping 200ms then sending 28-byte ConnectResponse
                 to 127.0.0.1:9001
         ISAAC seeds primed
  [live] step 4: got 28-byte datagram from :9001,
                 flags=EncryptedChecksum,TimeSync,          seq=2  OK
  [live] step 4: got 64-byte datagram from :9001,
                 flags=EncryptedChecksum,BlobFragments,     seq=3  OK
  [live] step 4: got 152-byte datagram from :9001,
                 flags=EncryptedChecksum,BlobFragments,     seq=4  OK
  [live] step 4: got 24-byte datagram from :9001,
                 flags=AckSequence,                         seq=4  OK
  [live] step 4: got 24-byte datagram from :9001,
                 flags=AckSequence,                         seq=4  OK
  [live] step 4 summary: 5 packets received, 5 decoded OK,
                         0 checksum failures

What each "OK" proves, reading left to right:
  * TimeSync (seq=2): our IsaacRandom is byte-compatible with ACE's
    ISAAC.cs — if a single bit were wrong in any state register the
    checksum key would mismatch and decode would fail. Our inbound
    ISAAC consumed one word for this packet.
  * BlobFragments (seq=3, 64 bytes): header hash + fragment hash +
    ISAAC key recipe all check out. These fragments contain the start
    of GameMessageCharacterList / ServerName / DDDInterrogation game
    messages ACE enqueues right after HandleConnectResponse. We don't
    parse game message bodies yet (Phase 4.7) but the fragments are
    fully retrievable from Packet.Fragments.
  * BlobFragments (seq=4, 152 bytes): continuation of the same game
    messages; our sequential ISAAC consumption handled two back-to-back
    encrypted packets correctly.
  * AckSequence (seq=4): unencrypted mixed with encrypted in the same
    stream — our codec handles both paths in one session.

Everything in AcDream.Core.Net is now proven byte-compatible with a
retail AC server at the protocol level. The remaining Phase 4 work
(4.6f, 4.7) is above the codec: parsing game message opcodes out of
the fragment payloads and routing CreateObject into IGameState so
acdream can show the foundry statue and the +Acdream character.

Test counts: 77 core + 73 net (+1 new live test) = 150 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:51:41 +02:00
Erik
0cb30aa0c8 feat(net): live ACE handshake verified — ConnectRequest received and parsed (Phase 4.6a/b/c/d)
Reaches the first major milestone of Phase 4: acdream's codec is proven
byte-compatible with a live ACE server. LiveHandshakeTests drives a real
UDP exchange against 127.0.0.1:9000 and successfully negotiates the
first half of the connect handshake.

Added:
  - Packets/PacketHeaderOptional.cs: new ConnectRequest flag branch.
    ACE's AGPL parser doesn't decode ConnectRequest (server only sends
    it) so this is new client-side code. Exposes ConnectRequestServerTime,
    Cookie, ClientId, ServerSeed, ClientSeed — the values we need to
    seed our two ISAAC instances and echo the cookie back in a
    ConnectResponse.
  - NetClient.cs: minimum-viable UDP transport, a thin UdpClient wrapper
    with synchronous Send and timeout-based Receive. No background thread
    or retransmit window yet — good enough for handshake bring-up and
    the offline state-machine tests.
  - LiveHandshakeTests.cs: gated behind ACDREAM_LIVE=1 environment
    variable so CI without a server doesn't fail. Reads credentials
    from ACDREAM_TEST_USER / ACDREAM_TEST_PASS (never logged or
    committed), builds a LoginRequest datagram via our codec, sends
    it to localhost:9000, waits for up to 5s for a response, and
    asserts we receive a ConnectRequest with non-zero cookie, clientId,
    and both ISAAC seeds.

Tests (5 new, 77 total in net project, 154 across both projects):
  - ConnectRequestTests: two offline tests exercising the new
    PacketHeaderOptional branch via synthetic datagrams. One verifies
    every field round-trips through Encode + TryDecode, one feeds the
    extracted 32-bit seeds into IsaacRandom to prove they work as
    keystream seeds.
  - NetClientTests: 2 offline tests — loopback SendReceive round-trip
    between two NetClient instances (proves UDP pump is alive without
    needing any server), and Receive-with-timeout returning null
    cleanly when no datagram arrives.
  - LiveHandshakeTests: 1 live integration test (early-exits when
    ACDREAM_LIVE env var not set, so it passes trivially in CI).

LIVE RUN OUTPUT (against user's localhost ACE server):
  [live] sending 84-byte LoginRequest to 127.0.0.1:9000 (user.len=11, pass.len=12)
  [live] received 52-byte datagram from 127.0.0.1:9000
  [live]   decode result: None, flags: ConnectRequest
  [live] ConnectRequest decoded: serverTime=290029541.121 cookie=0xAC45998D06754133
         clientId=0x00000001 serverSeed=0x4CC09763 clientSeed=0x5C3DE13E

Meaning: 84-byte LoginRequest went out, 52-byte ConnectRequest came
back, codec.TryDecode returned None error, every field parsed to a
sensible value. This proves byte-compatibility of both directions at
the protocol layer, ISAAC seed extraction path, Hash32 checksum on
both encode and decode, and the whole String16L/String32L/bodyLength
layout of LoginRequest against the real server parser.

Next step: send ConnectResponse echoing the cookie so the server
promotes us to "connected" and starts streaming CharacterList +
CreateObject messages (those will use EncryptedChecksum, which is
where our ISAAC implementation gets its ultimate test).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:46:19 +02:00
Erik
6cda431eae feat(net): PacketCodec.Encode — full outbound datagram assembly
Completes the encode side of the codec so acdream can stop hand-
assembling outbound packets in tests. Given a PacketHeader (with Flags
set, DataSize ignored/overwritten) and a body byte span, Encode:

  1. Overwrites header.DataSize with body.Length
  2. Parses the optional section out of the body (reusing
     PacketHeaderOptional.Parse as a length measurer) and hashes those bytes
  3. If BlobFragments is set, walks the body tail as back-to-back
     fragments and sums their Hash32s
  4. For unencrypted: header.Checksum = headerHash + optionalHash + fragmentHash
  5. For EncryptedChecksum: pulls one ISAAC keystream word and computes
     header.Checksum = headerHash + (isaacKey XOR payloadHash)
  6. Packs header + body into the final datagram

Tests (6 new, 67 total in net project, 144 across both test projects):
  - Unencrypted round-trip: Encode then TryDecode recovers the AckSequence
    field
  - DataSize is overwritten (caller can pass garbage)
  - Encrypted round-trip: two ISAACs with same seed, one encoding and
    one decoding, both agree on the keystream word
  - Encrypted but no ISAAC → throws InvalidOperationException
  - LoginRequest end-to-end: LoginRequest.Build → Encode → TryDecode →
    LoginRequest.Parse round-trips credentials exactly. This is the
    single most important integration test for the outbound side —
    every byte this exercises is exactly what acdream will put on the
    wire when Phase 4.6 goes live.
  - BlobFragments body with one embedded fragment: Encode preserves
    the fragment and fragmentHash is correctly folded into the checksum

Codec is now complete end-to-end (decode + encode) and has the
LoginRequest outbound path proven against its own decoder. The next
commit will wire NetClient over real UDP sockets and connect to the
localhost ACE server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:38:14 +02:00
Erik
44c335469a feat(net): PacketWriter + LoginRequest payload builder (Phase 4.5a/b)
Adds the outbound-side primitives acdream needs to send a LoginRequest
packet to an ACE server: a growable byte buffer writer with AC's two
length-prefixed string formats, plus the LoginRequest payload builder
and parser.

PacketWriter (Packets/PacketWriter.cs):
  - Growable byte[] buffer with little-endian WriteByte/UInt16/UInt32/Bytes
  - WriteString16L: u16 length + ASCII bytes + zero-pad to 4-byte boundary
    (pad counted from start of length prefix, matching ACE's Extensions.cs)
  - WriteString32L: u32 outer length (= asciiLen+1) + 1 marker byte (value
    ignored by reader, we emit 0) + ASCII + pad. Reader decrements the
    outer length by 1 when consuming the marker, so asciiLen is recovered
    correctly. Asserts ≤255 chars (two-byte-marker variant not needed for
    acdream's dev credentials).
  - ASCII encoding used instead of Windows-1252 since dev account names
    and passwords are ASCII-safe; can switch to CodePages later if a
    non-ASCII identifier ever turns up.

LoginRequest (Packets/LoginRequest.cs):
  - Build(account, password, timestamp, clientVersion="1802") produces
    the login payload bytes that go into the body of a packet whose
    header has the LoginRequest flag set
  - Parse(bytes) for tests and diagnostics — server never calls this
    in production, but round-trip tests make the writer self-verifying
  - NetAuthType enum mirrors ACE: Account/AccountPassword/GlsTicket
  - Wire layout per ACE's PacketInboundLoginRequest:
      String16L ClientVersion
      u32       bodyLength (bytes remaining after this field)
      u32       NetAuthType (2 = AccountPassword)
      u32       AuthFlags (0 for normal client)
      u32       Timestamp
      String16L Account
      String16L LoginAs (empty for non-admin)
      String32L Password (when AccountPassword)
  - bodyLength field is back-patched after the full body has been
    written (classic "write placeholder, come back and patch" flow)

Tests (17 new, 61 total in net project, 138 across both test projects):
  PacketWriter (11):
    - u32 little-endian
    - String16L: empty, 1/2/3-char with correct padding
    - String32L: 2-char short, empty, >255 throws
    - AlignTo4 no-op when aligned, pads when not
    - Buffer grows past initial capacity on big writes
  LoginRequest (6):
    - Build→Parse round-trip with realistic credentials (testaccount/
      testpassword/timestamp)
    - Empty account/password round-trip (padding edge case)
    - BodyLength field reflects actual remaining bytes after itself
    - Total wire size is multiple of 4 (sanity check on padding)
    - Different credentials produce different bytes
    - End-to-end: payload embedded in a full Packet with LoginRequest
      header flag + correct unencrypted checksum, PacketCodec.TryDecode
      parses it, BodyBytes round-trips back to the same credentials
      through LoginRequest.Parse

This gives acdream everything needed to construct the first datagram
of the handshake. Phase 4.5c next: WorldSession state machine to drive
the handshake sequence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:36:39 +02:00
Erik
c64bbf29e4 feat(net): PacketHeaderOptional + full packet decode + CRC verify (Phase 4.4)
Brings the codec to end-to-end: a raw UDP datagram goes in, a parsed
Packet comes out with verified CRC (both plain and ISAAC-encrypted
variants). Synthetic packets built inside tests round-trip through
TryDecode cleanly.

Added:
  - Packets/PacketHeaderOptional.cs: parses every flag-gated section
    that lives between the 20-byte header and the body fragments —
    AckSequence, RequestRetransmit (with count + array), RejectRetransmit,
    ServerSwitch, LoginRequest (tail slurp), WorldLoginRequest,
    ConnectResponse, CICMDCommand, TimeSync (double), EchoRequest (float),
    Flow (FlowBytes + FlowInterval). Records the raw consumed bytes into
    RawBytes so CalculateHash32 can hash them verbatim — AC's CRC requires
    hashing the optional section separately from the main header and the
    fragments.
  - Packets/Packet.cs: a record type bundling Header, Optional, Fragments,
    and the raw body bytes. Produced by the decoder, consumed by downstream
    handlers in Phase 4.5.
  - Packets/PacketCodec.cs: TryDecode(datagram, isaac?) that
      1. Unpacks the header,
      2. Bounds-checks DataSize against the buffer,
      3. Parses the optional section,
      4. If BlobFragments is set, walks the body tail as back-to-back
         MessageFragment.TryParse calls,
      5. Computes headerHash + optionalHash + fragmentHash,
      6. Verifies CRC:
         - Unencrypted: sum equals header.Checksum
         - Encrypted: (header.Checksum - headerHash) XOR payloadHash must
           equal the next ISAAC keystream word (which is consumed on match)
    Returns a PacketDecodeResult(Packet?, DecodeError) so callers can log
    and drop malformed packets instead of throwing.
  - Public helper PacketCodec.CalculateFragmentHash32 so tests (and later
    the encode path) can reuse the fragment-hash math.

Tests (7 new, 44 total in net project, 121 across both test projects):
  - Minimal valid packet with AckSequence optional, no fragments, plain
    checksum — verifies optional parse + CRC accept
  - Wrong checksum rejected
  - Buffer shorter than header → TooShort
  - Header DataSize > buffer → HeaderSizeExceedsBuffer
  - Packet with BlobFragments flag + one fragment: parses fragment and
    validates the full headerHash + fragmentHash equals wire checksum
  - Encrypted checksum ROUND TRIP: two ISAAC instances with same seed,
    one encodes the checksum key, one decodes — validates the
    (Header.Checksum - headerHash) XOR payloadHash == isaacNext contract
    byte-for-byte
  - Encrypted checksum with wrong key on the wire → rejected

Known limitation: the parser advances past WorldLoginRequest and
ConnectResponse their full 8 bytes whereas ACE "peeks" them (seek/reset).
The on-wire byte count is the same, only the read-position behavior
differs; any consumer that wanted to re-read those sections can do so
from Packet.BodyBytes.

Phase 4.5 (NetClient UDP pump + handshake state machine) next.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:24:29 +02:00
Erik
3226c4bcab feat(net): message fragment header + fragment + assembler (Phase 4.3)
Ports the fragment layer of the AC UDP protocol. A UDP packet's body is
zero or more message fragments back-to-back; a logical GameMessage that
doesn't fit in ~448 bytes gets split across multiple fragments sharing
the same Id with differing Index values. The assembler handles
reassembly across arbitrary arrival ordering and duplicate fragments.

Added (all reimplemented from ACE's AGPL reference, see NOTICE.md):
  - Packets/MessageFragmentHeader.cs: 16-byte fragment header struct
    with Pack/Unpack, constants for MaxFragmentSize (464) and
    MaxFragmentDataSize (448). Bit-layout doc comment documents what
    each field is for.
  - Packets/MessageFragment.cs: readonly record struct bundling a
    header with its payload bytes; TryParse(source) parses one fragment
    from the start of a buffer and returns (fragment, consumed) for
    incremental parsing of multi-fragment packets. Refuses to parse
    fragments with impossible TotalSize (too small for header, too
    large for the 464-byte max, or larger than the source buffer).
  - Packets/FragmentAssembler.cs: buffers partial messages keyed by
    fragment Id. Ingest(frag, out queue) returns the assembled byte[]
    when the last fragment arrives, null while still waiting. Key
    correctness properties, all tested:
      * Single-fragment (Count=1) shortcut releases with no buffering
      * Out-of-order arrival (e.g. 2, 0, 1) releases on last arrival
        and assembles in INDEX order, not arrival order
      * Duplicate-fragment idempotence (re-sending same index is a no-op)
      * Missing fragments stay buffered; DropAll() forcibly clears them
      * Two independent messages can be assembled in parallel without
        interfering
      * messageQueue captured from first-arriving fragment (it's a
        property of the logical message, not individual fragments)

Tests (17 new, 37 total in net project, 114 across both test projects):
  - MessageFragmentHeader (4): pack/unpack round-trip, little-endian
    wire format, constants, size-check throw
  - MessageFragment (6): complete parse, insufficient header, oversized
    TotalSize, undersized TotalSize, incomplete body, two-back-to-back
    incremental parse
  - FragmentAssembler (7): single-fragment, in-order 3-fragment,
    out-of-order 3-fragment (tests index-order assembly), duplicate
    idempotence, missing-fragment buffered, two parallel messages,
    DropAll

Phase 4.4 (GameMessage reader + opcode handlers) next.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:20:53 +02:00
Erik
18e308fe85 feat(net): PacketHeader + PacketHeaderFlags + Hash32 checksum (Phase 4.2)
Adds the 20-byte AC UDP packet header struct + pack/unpack + its
checksum helper, and the Hash32 primitive the checksum uses.

Hash32 (Cryptography/Hash32.cs):
  - Seeds accumulator with length << 16
  - Sums input as little-endian uint32s word-aligned
  - Folds any trailing 1-3 bytes via descending shift (24 → 16 → 8)
  - Hand-computed golden values for 4-byte, 5-byte, and each 1/2/3
    tail-byte case — no oracle needed, algorithm is simple enough to
    verify by tracing

PacketHeader (Packets/PacketHeader.cs):
  - Pack/Unpack: Sequence, Flags, Checksum, Id, Time, DataSize, Iteration
    (20 bytes, little-endian on the wire)
  - CalculateHeaderHash32: substitutes the 0xBADD70DD sentinel for the
    Checksum field before hashing (matches AC retail + ACE convention —
    without it the checksum would chicken-and-egg on itself). Uses a
    local struct copy so the real Checksum isn't mutated on the caller.
  - HasFlag for bitmask queries

PacketHeaderFlags (Packets/PacketHeaderFlags.cs):
  - Full flag enum from ACE reference: Retransmission, EncryptedChecksum,
    BlobFragments, ServerSwitch, ConnectRequest/Response, LoginRequest,
    AckSequence, TimeSync, Disconnect, NetError, EchoRequest/Response,
    Flow, and friends

Tests (15 new, 20 total in net project, 97 across both projects):
  Hash32 (7):
    - Empty returns 0
    - 4-byte known value (hand-computed from bit layout)
    - 5-byte value with one tail byte
    - 1/2/3 tail-byte boundary cases (verifies 24/16/8 shift ordering)
    - Determinism
  PacketHeader (8):
    - Pack/Unpack round-trip preserving all 7 fields
    - Pack writes little-endian wire format in byte order
    - HasFlag single and multi-bit
    - CalculateHeaderHash32 invariance under Checksum field changes
      (the critical property — verifies the BADD sentinel substitution)
    - CalculateHeaderHash32 doesn't mutate
    - CalculateHeaderHash32 determinism
    - Unpack/Pack size-check throw

User confirmed an ACE server is running on localhost for the future
Phase 4.6 live integration step. Credentials will be read from env
vars at runtime, never committed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:17:37 +02:00
Erik
293584d6e8 feat(net): AcDream.Core.Net scaffold + ISAAC keystream (Phase 4.1)
First step of Phase 4 (networking). Adds a new AcDream.Core.Net project
for the AC UDP protocol implementation and a matching AcDream.Core.Net.Tests
project. Keeps networking isolated from rendering and the dat layer,
which also keeps the AGPL-reference-material hygiene cleaner.

AcDream.Core.Net/NOTICE.md documents the attribution policy: we read
ACE's AGPL network code (and holtburger's Rust ac-protocol crate) to
understand AC's wire format, but we reimplement everything in acdream's
own style. Wire-format facts aren't copyrightable; specific code is.

This commit adds one component: IsaacRandom — AC's variant of Bob
Jenkins' ISAAC PRNG, used to XOR a keystream into the CRC field of
every outbound packet for authentication. Clean-room reimplementation
based on reading:
  - references/ACE/Source/ACE.Common/Cryptography/ISAAC.cs (AGPL oracle)
  - Bob Jenkins' public ISAAC algorithm description

Implementation notes:
  - 256 uint32 mm[] state, 256 uint32 rsl[] output buffer, a/b/c regs
  - Initialize() runs 4 golden-ratio Mix() warmup rounds then two fold-in
    passes over rsl[] and mm[] (fresh instance → both start as zeroes)
  - AC variant: seed is exactly 4 bytes, interpreted as little-endian
    uint32 assigned to a = b = c before the first Scramble()
  - Scramble() produces 256 output words in one pass; Next() consumes
    them backwards from offset 255 → 0, re-scrambling at offset -1
  - Test seed 0x12345678 matches ACE's reference output byte-for-byte
    across the first 16 values (golden vectors transcribed from a
    throwaway oracle harness that compiled ACE's ISAAC.cs and printed
    its output; the harness was deleted after extracting the values)

Tests (5, all passing):
  - Next_Seed12345678_MatchesAceGoldenVectors: 16 golden uint32 values
  - Next_TwoInstancesSameSeed_ProduceIdenticalSequence: 1000 outputs
  - Next_DifferentSeeds_ProduceDifferentFirstOutput
  - Next_512Calls_SpansTwoScrambleBatches: >400 distinct values in 512
    outputs (catches all-zero / stuck-at-one bugs at scramble boundary)
  - Ctor_ShortSeed_Throws

Both test projects still green: 77 core + 5 net = 82/82.

Phase 4.2 (packet framing + checksum) next.

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