Commit graph

89 commits

Author SHA1 Message Date
Erik
078919cc18 feat(net): #13 register PD trailer inventory+equipped in ItemRepository
After PlayerDescription is dispatched, the Inventory and Equipped lists
produced by the parser are now fed into ItemRepository via AddOrUpdate +
MoveItem so inventory/paperdoll panels see items after login.

Acceptance test PlayerDescription_RegistersInventoryEntries_InItemRepository
confirms ItemCount goes 0→2 for a synthetic PD with two inventory entries.
282 Net.Tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 09:43:46 +02:00
Erik
91693ea44c feat(net): #13 heuristic inventory locator after gameplay_options blob
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 09:37:46 +02:00
Erik
d9a5e40203 feat(net): #13 strict inventory+equipped reader (no GAMEPLAY_OPTIONS)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:49:10 +02:00
Erik
98eebef740 feat(net): #13 read options2 gated on CHARACTER_OPTIONS2 flag
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:46:32 +02:00
Erik
b17dc3b152 feat(net): #13 read optional spellbook_filters u32 2026-05-10 08:44:05 +02:00
Erik
75e8e260f2 feat(net): #13 read desired_comps list in PD trailer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:39:31 +02:00
Erik
8cbb991d95 feat(net): #13 read hotbar spells (SPELL_LISTS8 + legacy path)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:35:03 +02:00
Erik
f7a5eea8e8 feat(net): #13 read shortcuts list (SHORTCUT bit) in PD trailer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:28:25 +02:00
Erik
9a0dfe03da refactor(net): #13 Parsed.TrailerTruncated + diag logging
Code-quality review followup on Task 2 (becbde6) — addresses I1 (the
forward-looking concern that Tasks 3-9's inner-catch will leave partial
lists visible to callers with no signal) and M1 (silent inner catch).

Changes:
  - Parsed gains a trailing `bool TrailerTruncated` field. Both
    construction sites pass `false` by default; the trailer try/catch
    flips a local `trailerTruncated` to `true` on FormatException and
    feeds it into the final return.
  - Inner catch logs `pos`/`payload.Length`/exception message under
    ACDREAM_DUMP_VITALS=1, mirroring the outer catch's diagnostic
    pattern.
  - Task 2 test strengthened to assert defaults on Options2 /
    SpellbookFilters / HotbarSpells / DesiredComps / GameplayOptions /
    Equipped + TrailerTruncated=false (M2 followup — gives Tasks 3-9
    a regression guard if they consume into the wrong field).
  - New test `TryParse_TrailerAbsent_LessThan8BytesAfterEnchantments_*`
    documents the contract that <8 bytes after enchantments means the
    trailer is absent (not truncated): TrailerTruncated stays false,
    upstream attribute data survives.
  - Plan updated in lockstep so Tasks 3-11 implementers see the
    `trailerTruncated` local and the new return-arg position.

271/271 AcDream.Core.Net.Tests pass.
2026-05-10 08:26:08 +02:00
Erik
becbde60a4 feat(net): #13 read OptionFlags + Options1 after enchantments
First step of the PD trailer walk. Wraps trailer reads in their own
try/catch so a malformed trailer does not null out the upstream
attribute/skill/spell/enchantment data we already extracted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:18:38 +02:00
Erik
65870349a8 refactor(net): #13 rename Shortcut → ShortcutEntry, expand doc citations
Code review nit-fix on top of d3b58c9 — addresses two issues from the
quality review of Task 1:

  I1 (Important): the record struct `Shortcut` was a homograph with the
  flag member `CharacterOptionDataFlag.Shortcut`. Both names live inside
  `PlayerDescriptionParser`'s scope. Rename to `ShortcutEntry` aligns
  with `InventoryEntry`/`EquippedEntry` and removes the trap before
  Task 3's walker references both names in the same method body.

  M2 (Minor): `EquippedEntry` had no holtburger source citation; added
  one referencing events.rs:180-190. Also expanded `InventoryEntry`'s
  comment with the strict reader's validation reference.

Plan doc updated in lockstep so Task 3+ implementers see the new name.
8/8 PlayerDescriptionParser tests still pass.
2026-05-10 08:16:01 +02:00
Erik
d3b58c97e0 feat(net): #13 scaffold trailer fields on PlayerDescriptionParser.Parsed
No behavior change yet — adds CharacterOptionDataFlag, Shortcut/Inventory/
EquippedEntry records, and extends Parsed with trailer fields filled with
empty defaults. Sets up the per-section TDD walk in subsequent commits.
2026-05-10 08:06:33 +02:00
Erik
e471527924 feat(net): wire 0xF625 ObjDescEvent for live appearance updates
Retail-driven players observed from acdream rendered with stale
appearance — wrong skin/hair palettes, missing clothing — because
ACE's mid-session appearance broadcasts (equip/unequip/tailoring/
recipe/option-toggle) ride opcode 0xF625 ObjDescEvent and acdream
silently dropped them. Initial CreateObject carries the appearance
at spawn time, but every later equip change only updates via 0xF625
(per Skunkwors protocol docs in ACE/.../GameMessageObjDescEvent.cs).
Retail handles via SmartBox::HandleObjDescEvent (named-retail 0x453340).

Why: the retail observer sees the *server-relayed* view of remotes,
not retail's local build, so dropping ObjDescEvent freezes appearance
at the partial state in the first CreateObject.

How:
- Extract CreateObject's ModelData parsing into reusable
  CreateObject.ReadModelData(span, ref pos) returning
  (BasePaletteId, SubPalettes, TextureChanges, AnimPartChanges).
- Add ObjDescEvent.cs (parser for 0xF625):
  body = u32 opcode | u32 guid | ModelData | u32 instanceSeq | u32 visualDescSeq.
- WorldSession.AppearanceUpdated event + dispatcher branch.
- GameWindow.OnLiveAppearanceUpdated splices new ModelData onto the
  cached spawn and replays via OnLiveEntitySpawned. The dedup at the
  start of OnLiveEntitySpawnedLocked tears down the old GPU/animated/
  collision state cleanly before rebuild.
- _lastSpawnByGuid cache populated at spawn-end and tracked through
  UpdatePosition so re-applies use current position (no pop-back to
  login spot on equip toggle).
- ACDREAM_DUMP_APPEARANCE=1 env var prints structured SP/TC/APC
  decode for every 0xF625 — replaces the earlier raw-hex preview.
- ACDREAM_DUMP_CLOTHING extended with setup.Parts.Count, flatten.Count,
  and per-part triangle counts for offline polygon-budget audit.

Tests: 4 new ObjDescEvent tests (round-trip + parser drift guard);
269 net tests green. User-verified live: skin/hair colors match
retail's character data; equip/unequip no longer pops position.

Note: a separate "puffy arms / bulky body" geometry issue remains
where base body parts visibly overlap clothing meshes — different
root cause, tracked separately.
2026-05-06 10:46:14 +02:00
Erik
5d717312cc feat(net): plumb IsGrounded through EntityPositionUpdate (L.3.2 Task 2)
PositionFlags.IsGrounded (0x04) was already parsed by UpdatePosition
but not exposed through the Parsed record or EntityPositionUpdate.
Adds the bool field to both records so OnLivePositionUpdated can
consume it for retail-faithful MoveOrTeleport routing
(acclient @ 0x00516330: has_contact=false → no-op during airborne arc).

Consumed in subsequent task (L.3.1+L.3.2 Task 3).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 10:15:02 +02:00
Erik
851e88364d feat(net): L.3b — capture per-object friction + elasticity from CreateObject
Companion to L.3a (a1c27b3) which ported the velocity-reflection bounce.
Previously the CreateObject parser did `pos += 4` for both Friction and
Elasticity floats — silently dropping the wire data so every entity got
the PhysicsBody constructor default (0.05 elasticity, 0.5 friction).

Server-set bouncier surfaces or stickier objects therefore felt
identical to inert walls on collision. Inelastic projectiles via
PhysicsState bit 0x20000 (already plumbed in Commit A) had no per-
object elasticity to override.

Now the parser captures the floats, surfaces them on Parsed +
EntitySpawn, leaving the values at default (null) when their
PhysicsDescriptionFlag bits aren't set. Subscribers (e.g., the
remote-entity dead-reckoning path, future spell-projectile rendering)
can apply them when they wire elasticity to PhysicsBody.Elasticity.

The local player's PhysicsBody is constructed at controller init,
not from a CreateObject — so this commit alone produces no
user-visible local-player change. Effect lands when remote/projectile
physics consume EntitySpawn.Elasticity.

Files:
- CreateObject.cs:284-294: declare friction + elasticity accumulators.
- CreateObject.cs:467-487: parse floats instead of skipping.
- CreateObject.cs:543-555: propagate to Parsed via both return paths.
- WorldSession.cs:67-71: extend EntitySpawn record.
- WorldSession.cs:665-668: pipe through to subscribers.

Tests: 1491 still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:43:27 +02:00
Erik
ffefc6977f feat(physics): live-entity collision plumbing (Commit A)
Plumbing-only foundation for the upcoming live-entity (NPC / monster
/ player) collision port. No behavior change — the new fields default
to zero/None so the 5 existing static-entity Register call sites in
GameWindow.cs are untouched.

Wire layer:
- CreateObject parser now surfaces PhysicsState (acclient.h:2815 —
  ETHEREAL_PS=0x4, IGNORE_COLLISIONS_PS=0x10, HAS_PHYSICS_BSP_PS=0x10000,
  ...) which the parser previously dropped at line ~337 with a bare
  `pos += 4`.
- CreateObject parser now surfaces ObjectDescriptionFlags (the retail
  PWD._bitfield trailer per acclient.h:6431-6463), where
  acclient_2013_pseudo_c.txt:406898-406918 ACCWeenieObject::IsPK /
  IsPKLite / IsImpenetrable read bits 5 / 25 / 21 directly. Previously
  read-and-discarded.
- WorldSession.EntitySpawn carries both new fields through to subscribers.

Physics layer:
- New `EntityCollisionFlags` enum (IsPlayer / IsCreature / IsPK /
  IsPKLite / IsImpenetrable) + `FromPwdBitfield` helper. Bit
  positions verified against retail's SetPlayerKillerStatus (
  acclient_2013_pseudo_c.txt:441868-441890) which maps
  PKStatusEnum→bitfield exactly: PK=0x4→bit5, PKLite=0x40→bit25,
  Free=0x20→bit21.
- `ShadowEntry` extended with `State` (raw PhysicsState bits) +
  `Flags` (decoded EntityCollisionFlags). Backward-compatible — all
  five existing landblock-entity Register call sites omit them.
- `ShadowObjectRegistry.UpdatePosition(entityId, pos, rot, ...)` —
  fast-path for the 5–10 Hz UpdatePosition (0xF748) stream the server
  emits per visible entity. Reuses the entry's existing shape +
  state + flags. Mirrors retail's CPhysicsObj::SetPosition
  (acclient_2013_pseudo_c.txt:284276) which keeps the same shape and
  re-registers cell membership.
- `ObjectInfoState` adds `IsPK = 0x800` and `IsPKLite = 0x1000`
  matching retail's OBJECTINFO::state bits (acclient.h:6190-6194).
  Used by Commit C's PvP exemption gate.

Tests:
- `EntityCollisionFlagsTests` — 7 tests covering empty / each bit
  alone / PK+player combo / unrelated-bit ignore.
- `ShadowObjectRegistryTests` — 5 new tests: UpdatePosition moves
  entry to new cell, preserves State/Flags, unregistered no-op,
  Register stores State/Flags, defaults are zero/None.
- `CreateObjectTests` — 3 new tests verifying PhysicsState + PWD
  bitfield (with PK / PKLite bit cases) parse and surface.

1454 → 1454 + 15 = covered by suite. dotnet build + dotnet test
green.

Foundation for Commit B (live-entity registration) and Commit C
(PvP exemption block in FindObjCollisions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:12:56 +02:00
Erik
186a584404 feat(anim): Phase L.1c port MoveTo path data + per-tick steer
Root-causing the user-reported "monsters disappearing some time +
laggy/jittery locomotion" via systematic-debugging Phase 1: our
UpdateMotion parser kept only speed/runRate/flags from a movementType
6/7 packet and discarded Origin (destination), targetGuid, and the
distance/walkRunThreshold/desiredHeading half of MovementParameters.
The integrator consequently held Body.Velocity at zero during MoveTo
("incomplete state" stabilizer 882a07c), so the body froze with legs
animating until UpdatePosition snap-teleported it — sometimes outside
the visible window (disappearing) — and constant-velocity drift along
the old heading between snaps produced jitter on every UP correction.

The 882a07c stabilizer was deliberately conservative because the state
WAS incomplete. Completing the data plumbing makes its restriction
moot: with the full MoveTo payload captured, the body solver has every
field retail's MoveToManager::HandleMoveToPosition (0x00529d80) reads.

Why: server re-emits MoveTo packets ~1 Hz with refreshed Origin while
chasing — verified in the live log (guid 0x800003B5 seq 0x01FE→0x0204
all show different cell/xyz floats). Those are heading updates we'd
been throwing away. With the full payload retained, the per-tick driver
steers body orientation toward Origin (±20° snap tolerance, π/2 rad/s
turn rate above tolerance) and lets apply_current_movement fill in
Velocity from the existing RunForward cycle — no new motion path,
just the right heading.

Scope is the minimum viable subset: target re-tracking, sticky/StickTo,
fail-distance progress detector, and sphere-cylinder distance are
server-side concerns we don't need (server's emit cadence handles all
of them). MoveToObject_Internal target-guid resolution is also skipped
— Origin is refreshed each packet, so the effective target tracks the
real entity even without a guid lookup.

Cross-references:
- docs/research/named-retail/acclient_2013_pseudo_c.txt — MoveToManager
  + MovementParameters::UnPackNet (0x0052ac50) + apply_run_to_command
  (0x00527be0). 18,366 named PDB symbols make this the primary oracle.
- references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs
  — port aid; flagged divergences (WalkRunThreshold default, set_heading
  snap, inRange one-shot) called out in the new pseudocode doc.
- docs/research/2026-04-28-remote-moveto-pseudocode.md — pseudocode +
  ACE divergence flags + out-of-scope list per CLAUDE.md mandatory
  workflow (decompile → cross-reference → pseudocode → port).

Tests: 1404 → 1412 (parser type-7 path retention + type-6 target guid
retention; driver arrival, in-tolerance snap, beyond-tolerance step,
behind-target shortest-path turn, arrival preserves orientation,
Origin→world landblock-grid arithmetic).

Pending visual sign-off — handoff stabilizer 882a07c was the last
commit the user tested.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:49:22 +02:00
Erik
9812965183 fix(anim): Phase L.1c match MoveTo run speed
Retail MovementManager::PerformMovement (0x00524440) reads MoveTo speed and runRate from the packet, MovementParameters::UnPackNet (0x0052AC50) defines the layout, and CMotionInterp::apply_run_to_command (0x00527BE0) multiplies RunForward by runRate. Parse those fields for UpdateMotion/CreateObject, seed server-controlled MoveTo locomotion with the retail speed multiplier, and avoid overriding active monster MoveTo with sparse UpdatePosition-derived velocity.
2026-04-28 20:58:22 +02:00
Erik
7656fe0970 fix(anim): Phase L.1c animate server-controlled chase 2026-04-28 19:38:52 +02:00
Erik
b96b680a20 fix(anim): Phase L.1c route creature actions and despawns
Handle retail ObjectDelete (0xF747) using CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 / SmartBox::HandleDeleteObject 0x00451EA0 and ACE GameMessageDeleteObject so dead creatures are removed when corpses spawn.

Route action-class ForwardCommand values through AnimationCommandRouter/PlayAction instead of SetCycle so creature attack commands 0x51/0x52/0x53 survive the immediate Ready echo, matching CMotionTable::GetObjectSequence 0x00522860 / ACE MotionTable.GetObjectSequence.

Use server-authoritative UpdatePosition velocity, or observed server position delta for non-player entities when HasVelocity is absent, to reduce monster/NPC chase lag without applying player RUM prediction to server-controlled creatures.
2026-04-28 19:21:02 +02:00
Erik
4874d8595a feat(combat): Phase L.1c wire live attack input 2026-04-28 11:58:57 +02:00
Erik
25b9616703 feat(combat): Phase L.1c add outbound combat actions 2026-04-28 10:57:12 +02:00
Erik
29afc94b94 fix(net): Phase L.1c conform combat wire events 2026-04-28 10:54:50 +02:00
Erik
0ebf0cad09 fix(net): VectorUpdate parser was reading guid from opcode bytes — remote jumps invisible
User report: "in ACdream client, when other client is jumping,
nothing happens at all".

Diagnostic [VU.recv] revealed the parser was reading
guid = 0x0000F74E (= the opcode itself) and velocity values in
the billions:

  [VU.recv] guid=0x0000F74E vel=(8589944832.00,0.00,0.00)
            isLocal=False hasRemote=False

WorldSession.ProcessDatagram passes the FULL reassembled body
including the 4-byte opcode at offset 0 — every other parser
in src/AcDream.Core.Net/Messages/ verifies the opcode word
before reading payload (UpdateMotion.TryParse:77,
UpdatePosition.TryParse, etc.). VectorUpdate.TryParse skipped
that step and read every field shifted four bytes early,
making the guid the opcode bytes and the velocities random
floats from later in the buffer. With guid=0xF74E never
matching any tracked entity, OnLiveVectorUpdated returned
early and remote jumps rendered nothing.

Fix: read + verify opcode at offset 0 in TryParse, then read
guid at offset 4, velocity at 8/12/16, omega at 20/24/28,
sequences at 32/34. Body length now 4 (opcode) + 32 (payload).

Tests stay 1222 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:51:36 +02:00
Erik
a060f4fc98 fix(player): apply AttributeFormula to wire-derived Run/Jump skill — root cause of short jumps
Found the underlying cause of the user's persistent
"jumps don't reach retail height" complaint. The wire's SkillEntry
`init` field is ONLY the InitLevel (training/specialized
chargen bonus, per ACE GameEventPlayerDescription.cs:317
"init_level, for training/specialized bonus from character
creation"). It does NOT include the AttributeFormula
contribution.

ACE's CreatureSkill.Current is computed as:
  AttributeFormula(skill, attrs) + InitLevel + Ranks
  + augs + multipliers - vitae

Pre-fix13 we used `init + ranks` only — dropping the
AttributeFormula term, which is the DOMINANT component for
movement skills (50-100 points typical). For our character
that meant Jump skill 208 instead of the actual ~280-310,
giving a 3.11 m peak instead of the retail ~4 m peak. Hence
"feels like the upward acceleration is too slow and we don't
reach the same height".

Fix:
- GameWindow caches portal.dat's SkillTable (0x0E000004u) at
  WireAll time. Each entry has a SkillFormula with attr1/
  attr2/multipliers/divisor/additive constants
  (formula:  bonus = (attr1*M1 + attr2*M2)/Div + Additive).
- GameEventWiring.WireAll gains a
  `resolveSkillFormulaBonus(skillId, attrCurrents)` callback.
  GameWindow plugs in a resolver that looks up
  SkillTable.Skills[skillId].Formula, applies the formula
  using the player's current attribute values from PD.
- The PD handler builds attrId→current map (ranks+start) from
  the parsed attributes before iterating skills, then passes
  it to the resolver for Run (24) and Jump (22).
- Total skill = formulaBonus + InitLevel + Ranks. Matches ACE
  Current minus augs/multipliers/vitae (close enough — those
  add maybe ±10 % at most).

ACDREAM_DUMP_VITALS=1 logs add a per-skill line:
  "vitals: PD-skill id=22 init=N ranks=N formulaBonus=N total=N"
so live testing can confirm the formula is applied.

Tests stay 1222 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:56:38 +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
1fce21034a feat(player): use server-authoritative Run + Jump skill values from PlayerDescription
Before this commit, PlayerWeenie used hardcoded ACDREAM_RUN_SKILL
(default 200) and ACDREAM_JUMP_SKILL (default 300) regardless of
the actual character skill. PlayerDescription's skill table HAS
been parsed since Phase H, but the values weren't plumbed into
PlayerMovementController, so a high-Jump character still got the
3-4m default arc instead of their real 5m+ arc, and a low-Jump
character got too much.

GameEventWiring.WireAll gains an optional `onSkillsUpdated`
callback. The PlayerDescription handler scans the parsed skill
table for SkillId 24 (Run) and SkillId 22 (Jump) — ACE Skill enum
ordinals from references/ACE/.../Enum/Skill.cs:11-37 — and fires
the callback with `init + ranks` for each (the holtburger-named
"init" field is the attribute-derived initial component, ranks
is XP-bought additions; closest sane approximation of ACE's
CreatureSkill.Current short of porting Aug + Multiplier + Vitae
chains).

GameWindow stores the most recent values in _lastSeenRunSkill /
_lastSeenJumpSkill and pushes them into the controller at two
points:
  * Immediately if _playerController already exists (PD arriving
    mid-session, e.g. after a relog).
  * Inside EnterPlayerModeNow when constructing a fresh
    controller (the auto-entry path: PD always arrives at login
    before auto-entry fires, so this is the normal path).

Both sites also log "applied server skills run=X jump=Y" so live
testing can confirm the right values reached the formula.

Console output (ACDREAM_DUMP_VITALS=1) gains a "vitals: PD-skills
run=X jump=Y" line on every PlayerDescription with skill data.

Tests stay 1222 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:22:59 +02:00
Erik
3f7821c18d fix(chat): BuildTell wire field order + retail-style FormatEntry + suppress duplicate Channel echo
Three follow-up fixes from the 2026-04-25 live verify session.

1. CRITICAL: BuildTell wire field order. Our outbound layout was
   [target_name, message] but ACE's GameActionTell.Handle reads
   [message, target_name] (verified against
   references/ACE/.../GameActionTell.cs:17-18 verbatim). Result: every
   /tell since Phase I.3 has been failing with WeenieError 0x052B
   (CharacterNotAvailable) because ACE was looking up the message
   text as the recipient name. Swapped the field order in
   ChatRequests.BuildTell so message is written first; updated the
   pinned BuildTell test to expect the corrected layout. The
   WorldSessionChatTests round-trip continues to pass since SendTell
   delegates to BuildTell.

2. Retail-style FormatEntry. The user asked for the canonical retail
   strings:
     /say (own):       You say, "text"
     /say (incoming):  Name says, "text"
     /tell (own echo): You tell Caith, "text"
     /tell (incoming): Caith tells you, "text"
     channel:          [Trade] +Acdream says, "text"
     /shout (own):     You shout, "text"
     /shout (incoming):Name shouts, "text"

   Discriminators: SenderGuid == 0 distinguishes our own outbound
   echoes (set by OnSelfSent) from real incoming whispers (carry the
   sender's player guid). Sender == "" or "You" distinguishes our own
   /say echoes (OnLocalSpeech substitutes "You" when the wire sender
   is empty per holtburger client/messages.rs:476-487).

   ChatEntry gains a new ChannelName slot so Channel-kind entries
   render with the friendly room name ("Trade") instead of "ch 3".
   Falls back to "ch {ChannelId}" when ChannelName isn't populated
   (legacy ChatChannel inbound or older callers).

3. Suppress optimistic Channel echo. The user saw duplicates per
   /trade /lfg in the live trace:
     [ch 0] Trade: hello                     <-- our optimistic
     [ch 3] +Acdream: [Trade] hello          <-- ACE's TurbineChat broadcast
   ACE's TurbineChatHandler at Network/Handlers/TurbineChatHandler.cs
   broadcasts EventSendToRoom to ALL recipients in the room including
   the sender, so the canonical echo always arrives via 0xF7DE. Drop
   the optimistic OnSelfSent for Turbine kinds in GameWindow's
   SendChatCmd handler; trust the server. Legacy ChatChannel paths
   (Fellowship / Allegiance / Patron / Monarch / Vassals / CoVassals)
   keep the optimistic echo because the legacy 0x0147 broadcast may
   not always come back to the sender.

   Inbound TurbineChat also stops embedding "[Trade] " into the
   message text — passes the friendly name out-of-band via the new
   channelName parameter on ChatLog.OnChannelBroadcast.

11 tests updated for the new format strings (8 in ChatVMTests, 1 in
ChatVMCombatTests, 1 BuildTell, plus the format additions cover
incoming/outgoing variants per kind). Solution total: 1007 green
(243 + 114 + 650), 0 warnings.

Tells should now actually deliver. Channel echoes show as
[Trade] +Acdream says, "hello" without the duplicate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:49:02 +02:00
Erik
e17caa2942 fix(chat): translate WeenieError templates + strip Tell target punctuation + Turbine routing diagnostics
Three post-launch fixes from the 2026-04-25 live verify session.

1. WeenieError display bug. Many ACE WeenieError / WeenieErrorWithString
   codes are *informational*, not error-level — the user saw cryptic
   "WeenieError 0x051B: General" / "WeenieError 0x051D" at login, but
   those decode as "You have entered the General channel." and
   "Turbine Chat is enabled." per ACE WeenieError(WithString).cs
   templates. New static helper Core/Chat/WeenieErrorMessages.cs maps
   ~30 high-frequency codes to retail-faithful templates with `_`
   placeholder substitution. ChatLog.OnWeenieError now routes through
   Format(); unknown codes still fall back to "WeenieError 0xNNNN[: param]"
   so nothing is silently lost. New codes can be added in 30 seconds
   when the user reports one.

2. Tell target eats trailing punctuation. Retail muscle memory is
   "/t Name, message" — comma is the separator. Our split-on-whitespace
   pulled "Name," (with comma) as the target, server returned 0x052B
   "That person is not available now." because no such character.
   ChatInputParser.TryParseTargeted now strips a trailing ,;:.!? from
   the target token so "/t Caith, hi" and "/t Caith hi" both work.
   Added 7 Theory cases covering each separator + the long-form alias.

3. TurbineChat routing diagnostics. The user's ACE login showed the
   "TurbineChatIsEnabled" + "YouHaveEnteredThe_Channel" notifications
   for General/Trade/LFG, confirming TurbineChat IS active server-side.
   But outbound /g /trade /lfg might still fall back to legacy
   ChatChannel (which the server then rejects). Added diagnostic
   Console.WriteLines so the next launch shows:
     - "chat: SetTurbineChatChannels parsed enabled=true general=0x... ..."
       (when ACE sends the 0x0295 channel-id table)
     - "chat: outbound TurbineChat General room=0x... cookie=0x... len=N"
       (when SendChatCmd routes a Turbine kind through 0xF7DE)
     - "chat: outbound legacy ChatChannel Fellowship id=0x... len=N"
       (when SendChatCmd uses the legacy 0x0147 path)
     - "chat: SendChatCmd kind=General dropped (turbine.Enabled=false no legacy id)"
       (when neither path can dispatch — usually means ACE didn't send
       0x0295 yet and the kind is Turbine-only)

   Sets up Bug 3 (proper outbound TurbineChat for /g /trade /lfg) for
   a follow-up commit once the next live trace shows the actual flow.

18 new tests:
- WeenieErrorMessagesTests: 11 covering known templates + fallback.
- ChatInputParserTests: +7 Theory cases for trailing-punctuation strip.

Solution total: 1007 green (114 UI + 650 Core + 243 Core.Net), 0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:31:23 +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
196f883c10 fix(player): EnchantmentMask bit fix + Vitae key=0 + absolute Vitals overlay
Three fixes to the Vitals HUD path:

1. EnchantmentMask Vitae/Cooldown bit values (parser regression).
   ACE's enum at references/ACE/Source/ACE.Entity/Enum/EnchantmentCategory.cs
   has Vitae=0x4 and Cooldown=0x8. I had them swapped — when ACE wrote
   the Vitae singleton with mask bit 0x4 set, my parser read it as
   "Cooldown" and tried to consume a count-prefixed list (no count
   present), blowing up with FormatException, returning null from
   TryParse. PlayerDescription consequently failed to parse on every
   live login. Fix: swap the bit values + bucket constants to match ACE.

2. Vitae applies regardless of StatModKey. Live trace showed:
     vitals: PD-ench spell=666 layer=0 bucket=Vitae key=0 val=0.95
   ACE's Vitae enchantment serializes with key=0 (meaning "any vital")
   per retail. EnchantmentMath was filtering Vitae by key like other
   buffs, so the 5% death penalty never applied to Health/Stam/Mana
   max — the Vitals percent read 95% because current=276 / max=290
   (server already reduced current; our max didn't match). Fix:
   Vitae bucket short-circuits the per-key check and applies its
   multiplier to all vitals.

3. Absolute current/max in HUD overlay. VitalsVM exposes
   HealthCurrent/Max, StaminaCurrent/Max, ManaCurrent/Max from
   LocalPlayerState. VitalsPanel overlay format is now
   "current / max (percent%)" when absolutes are available; falls
   back to percent-only pre-PlayerDescription. Matches the retail
   look the user requested ("HP 400/400" style).

Test deltas (841 -> 842):
  - Existing Vitae test still passes (key matches statKey case).
  - New Vitae key=0 test pins the "any vital" semantics.
  - Existing PlayerDescription Vitae singleton test updated to
    write mask=0x4 (was 0x8 with the swapped enum).

Live verification: with +Acdream's Vitae-666 active and Endurance.current=290:
  HP   : current=138, max=145×0.95≈138 → bar 100% (was 95%)
  Stam : current=276, max=290×0.95≈276 → bar 100%
  Mana : current=190, max=200×0.95≈190 → bar 100%
Overlay reads e.g. "276 / 276 (100%)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:15:20 +02:00
Erik
bb5003a849 feat(net): #7 PlayerDescriptionParser - enchantment block walker + StatMod flow
Extends PlayerDescriptionParser past the spell block to parse the
Enchantment trailer per holtburger events.rs:462-501 +
magic/types.rs:40. New EnchantmentEntry record carries the full
60-64 byte wire payload:
  u16 spell_id, layer, spell_category, has_spell_set_id
  u32 power_level
  f64 start_time, duration
  u32 caster_guid
  f32 degrade_modifier, degrade_limit
  f64 last_time_degraded
  u32 stat_mod_type, stat_mod_key
  f32 stat_mod_value
  [u32 spell_set_id]?
  + EnchantmentBucket (Multiplicative / Additive / Cooldown / Vitae)

EnchantmentMask outer u32 selects which buckets follow; each bucket
(except Vitae) is u32 count + N records. Vitae is a singleton.

Parsed.Enchantments now exposed as IReadOnlyList<EnchantmentEntry>.
GameEventWiring routes each entry through Spellbook.OnEnchantmentAdded
with the full StatMod data + bucket. EnchantmentMath.GetMod consumes
StatMod records to produce real (Multiplier, Additive) per stat key:

  Bucket 1 (Multiplicative): multiplier *= val
  Bucket 2 (Additive):       additive += val
  Bucket 8 (Vitae):          multiplier *= val (applied last)
  Bucket 4 (Cooldown):       skipped (not a vital mod)

ActiveEnchantmentRecord extended with optional StatModType /
StatModKey / StatModValue / Bucket fields. Existing 4-arg callers
stay compatible (defaults to null / 0). New OnEnchantmentAdded
overload accepts the full record from PlayerDescription path.

Tests: 7 new (834 -> 841):
  - PlayerDescriptionParserTests (2): enchantment block schema with
    multiplicative + additive buckets, Vitae singleton.
  - EnchantmentMathTests (5): multiplicative buffs aggregate, additive
    buffs sum, stat-key mismatch filters out, Vitae applied
    multiplicatively, family-stacking picks higher spell-id.

Closes #7 (parser past spells, enchantment block parsed).
Closes #12 (StatMod flow architecture — data lights up #6's
aggregator). Files #13 (remaining trailer sections: options /
shortcuts / hotbars / desired_comps / spellbook_filters / options2 /
gameplay_options / inventory / equipped — needs the heuristic
gameplay_options walker per holtburger).

Note: ParseMagicUpdateEnchantment (live-update 0x02C2) NOT yet
extended — still uses 4-field summary. PlayerDescription is the
load-bearing path for #6; live updates can be folded in separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:01:22 +02:00
Erik
567078803f docs(issues): #8/#9/#11 filed; #10 wired (KillerNotification)
Files four new issues created by the 2026-04-25 PDB-discovery sprint:
  #8  (DONE 2026-04-25) — pdb-extract tool, shipped 69d884a
  #9  (OPEN)            — function-map address-correction sweep
                          (Phase E will close)
  #10 (DONE 2026-04-25) — wire KillerNotification (0x01AD); orphan
                          parser at GameEvents.ParseKillerNotification
                          existed but was never registered. This commit
                          adds CombatState.OnKillerNotification +
                          KillLanded event, registers the dispatcher
                          handler, and adds a regression test.
  #11 (OPEN)            — spell metadata loader (spells.csv → SpellTable)
                          (Phase F will close)

Code change is minimal — three lines of dispatch + a 12-line
CombatState method with a typed event for future killfeed UI.

818 tests passing (+1 KillerNotification).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:39:47 +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
d42bf5735d feat(player): #5 LocalPlayerState — Stam/Mana wired through PlayerDescription
Closes ISSUES.md #5. The Vitals devtools window now draws three bars
(HP / Stamina / Mana) once the server sends the first PlayerDescription
(0x0013), instead of HP only. Built test-first per CLAUDE.md TDD rule —
16 new tests went red before the implementation went in.

New AcDream.Core.Player.LocalPlayerState (cache):
  - {CurrentStamina, MaxStamina, CurrentMana, MaxMana} as uint? — null
    until first received.
  - StaminaPercent / ManaPercent: 0..1 fraction or null when either
    field is missing or max is zero. Clamps to 1.0 if current > max
    (server can briefly report this during buff transitions).
  - OnPlayerDescription preserves any previously known good value when
    an incoming field is null — partial profiles don't wipe state.
  - Changed event for future subscribers.

GameEventWiring.WireAll:
  - New optional 6th parameter: LocalPlayerState? localPlayer = null.
    Existing 5-arg call sites still work; without the parameter the new
    PlayerDescription handler still parses + feeds the spellbook but
    skips the cache update.
  - PlayerDescription (0x0013) shares AppraiseInfo wire format with
    IdentifyObjectResponse (0x00C9) per AppraiseInfoParser docstring,
    so the new handler reuses the existing parser and pulls
    CreatureProfile.{Stamina, StaminaMax, Mana, ManaMax}.
  - Player's full learned spellbook also lands here (previously only
    item-scoped Identify responses fed the spellbook).

VitalsVM:
  - Constructor adds optional LocalPlayerState? parameter (default null
    keeps every existing caller compiling).
  - StaminaPercent / ManaPercent now read through to LocalPlayerState
    every access — no VM-side caching, so a server-side delta to the
    cache surfaces next frame without any explicit refresh.

GameWindow:
  - Public readonly LocalPlayer field alongside Combat / Chat / Items /
    SpellBook so plugins + future panels can bind directly.
  - WireAll call updated to pass LocalPlayer.
  - VitalsVM construction passes LocalPlayer so the existing
    VitalsPanel automatically picks up the two new bars.

Test counts:
  - AcDream.Core.Tests:           550 → 561  (+11 LocalPlayerStateTests)
  - AcDream.UI.Abstractions.Tests: 23 →  26  (+3 VitalsVM through-cache)
  - AcDream.Core.Net.Tests:       192 → 194  (+2 PlayerDescription wiring)
  - Total:                        765 → 781

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:02:00 +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
340dabbc72 feat(anim): full retail remote-entity motion port — walk/run/strafe/turn/stop
Ports the retail client's client-side remote-entity motion pipeline
verbatim per the decompile research. Every remote now runs its own
PhysicsBody + MotionInterpreter + AnimationSequencer stack — retail has
no special "interpolator" for remotes, it runs the full motion state
machine on every entity. Now we do too.

## What changed

### Parser fixes (CreateObject, UpdateMotion)
Wire flag bits for InterpretedMotionState (per ACE MovementStateFlag enum):
  CurrentStyle=0x01, ForwardCommand=0x02, ForwardSpeed=0x04,
  SideStepCommand=0x08, SideStepSpeed=0x10, TurnCommand=0x20, TurnSpeed=0x40

Previously we only extracted CurrentStyle + ForwardCommand + ForwardSpeed
and SKIPPED the side/turn fields entirely. Result: we had zero rotation-
or strafe-intent data from the server — impossible to render turn or
sidestep animations. Now ServerMotionState carries all 7 fields and the
parser reads the bytes in ACE's write order (style, fwd, side, turn, then
fwdSpd, sideSpd, turnSpd).

### RemoteMotion (new per-remote struct in GameWindow)
Each remote gets its own PhysicsBody + MotionInterpreter + observed
angular velocity. Replaces the earlier shortcut RemoteInterpolator
(deleted — retail has no such thing).

On UpdateMotion:
  - ForwardCommand flag absent → stop signal (reset to Ready) per
    retail FUN_0051F260 bulk-copy semantics (absent = Invalid = default).
  - Forward + sidestep + turn each route through DoInterpretedMotion,
    exactly as retail FUN_00528F70 does.
  - Animation cycle selection: forward wins if active, else sidestep,
    else turn, else Ready. Matches the user's observation that retail
    plays turn animation when only turning.
  - Turn command seeds ObservedOmega = π/2 × turnSpeed (from Humanoid
    MotionData.Omega.Z ≈ π/2 per decompile).
  - Turn absent → ObservedOmega = 0 (stops rotation immediately).

On UpdatePosition:
  - Hard-snap Body.Position + Body.Orientation per retail FUN_00514b90
    set_frame (direct assignment, no slerp — retail does not soft-snap).
  - HasVelocity + |v| < 0.2 → StopCompletely + SetCycle(Ready).
  - ForwardSpeed=0 on wire is a VALID stop signal (ACE sends this when
    alt releases W); previously we defaulted to 1.0, causing the "slow
    walk that never stops" symptom.

Per-tick:
  - apply_current_movement → Body.Velocity via get_state_velocity
    (retail FUN_00528960: RunAnimSpeed × ForwardSpeed in body-local,
    rotated by orientation).
  - Manual omega integration: Orientation *= quat(ObservedOmega × dt).
    Bypasses PhysicsBody.update_object's MinQuantum=1/30s gate that
    was eating every-other-tick rotation updates at our 60fps render
    rate — the cause of the persistent "rotation snaps every UP" bug.
  - update_object still called for position integration and the motion
    subsystem it drives.

### AnimationSequencer synthesis extension
Added omega synthesis for TurnRight/TurnLeft cycles (same pattern as
the earlier velocity synthesis): when the Humanoid dat leaves HasOmega
clear, SetCycle synthesizes CurrentOmega = ±π/2 × speedMod on Z so
dead-reckoning and stop detection can read a non-zero omega for turn
cycles.

### Stop-detection heuristic removed
No more 300ms/2000ms/5000ms idle timers. Retail's stop signal is
explicit (UpdateMotion with ForwardCommand flag absent → Ready); we
handle it directly. Client-side timers were a source of flicker during
normal running.

## Confirmed working
- Walking (matches retail speed + leg cadence)
- Running (matches retail speed + leg cadence)
- Strafing (body moves sideways + strafe animation plays)
- Turning while stationary (body rotates smoothly + turn animation plays)
- Turning while running (body rotates + leg anim continues)
- Stopping (instant stop, no slow-walk tail)

All 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:26:23 +02:00
Erik
48b5e1f1b1 merge: sky/weather/lighting overhaul branch (Opus agent, 7 commits, +27 tests)
Ships full retail-faithful sky-object rendering, 5-kind weather with
deterministic per-day roll + storm lightning, dynamic-lighting shader
UBO with retail hard-cutoff semantics, per-entity torch LightSource
registration via Setup.Lights, ParticleRenderer for rain/snow, and
TimeSync handshake wiring. F7 / F10 debug keys for time/weather
cycling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:56:49 +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
862cd5662f merge: animation overhaul branch (Opus agent, 10 commits, +32 tests)
Resolves remote-chars-lagging-forward, no-anim-speed-scaling, and
monster/NPC Commands-list (waves/attacks/deaths) not animating.
Adds dead-reckoning + sequence-wide velocity/omega + Commands[]
list parsing + MotionCommandResolver + soft-snap residual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:50:47 +02:00
Erik
3f41872d88 feat(anim): route Commands[] list — full NPC/monster motion support
UpdateMotion's InterpretedMotionState payload includes not just
ForwardCommand but a whole Commands[] list of MotionItem entries — each
carrying an Action (attack, portal, skill use), Modifier (jump,
stop-turn), or ChatEmote (Wave, BowDeep, Laugh) that should overlay the
current cycle. The old parser stopped reading after ForwardSpeed, so
emotes/attacks/deaths never reached the sequencer and NPCs just sat in
their idle cycle.

Three parts:

1. New MotionItem wire record in ServerMotionState — carries Command
   (u16), PackedSequence (u16 with IsAutonomous bit + 15-bit stamp),
   and Speed (f32). Mirrors ACE Network/Motion/MotionItem.cs.

2. Both UpdateMotion.TryParse and CreateObject.TryParseMovementData
   now read the full InterpretedMotionState: all 7 flag fields
   (CurrentStyle, ForwardCommand, SidestepCommand, TurnCommand,
   ForwardSpeed, SidestepSpeed, TurnSpeed) plus the numCommands ×
   MotionItem tail. The packed u32 encodes flags in low 7 bits and
   command count in bits 7+ (see ACE InterpretedMotionState.cs:131).

3. New MotionCommandResolver — reconstructs the 32-bit MotionCommand
   class byte from a 16-bit wire value via a reflection-built lookup
   of DatReaderWriter.Enums.MotionCommand. Server serializes as u16
   (ACE InterpretedMotionState.cs:139) and we need the class to route:
     - 0x10xxxxxx Action / 0x20xxxxxx Modifier / 0x12,0x13 ChatEmote →
       PlayAction (resolves from Modifiers or Links dict, overlays on
       current cycle)
     - 0x40xxxxxx SubState → SetCycle (cycle change)

4. OnLiveMotionUpdated in GameWindow dispatches each command:
     - SubState class (0x40xxx) → SetCycle (treated same as
       ForwardCommand)
     - Action/Modifier/ChatEmote → PlayAction — the link animation
       plays once then drops back to the current cycle naturally
       (matches retail's action-queue pattern in CMotionInterp
       DoInterpretedMotion, decompile FUN_00528F70).

Result: NPCs now animate attacks, waves, bows, death throes, and other
one-shots that ACE broadcasts via the Commands list rather than the
primary ForwardCommand field. Combined with the dead-reckoning + speed-
scaling from the prior commits, remote characters look visually correct
during the full motion spectrum (idle → walk → run → attack → death).

Tests: 2 new UpdateMotion wire-format tests (ForwardSpeed parse, full
Wave command list parse) + 19 new MotionCommandResolver reconstruction
tests covering SubState, Action, and ChatEmote classes. 654 tests green
(was 633).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:34:18 +02:00
Erik
3fd9f264b7 feat(net): 9 more GameEvent payload parsers
Extends GameEvents.cs with parsers for commonly-observed events that
had been routed-but-unparsed by the F.1 dispatcher:

- UseDone (0x01C7): u32 weenieError — Use/UseWithTarget completion.
- InventoryPutObjectIn3D (0x019A): u32 itemGuid — server dropped item.
- InventoryServerSaveFailed (0x00A0): u32 itemGuid — rollback signal.
- CloseGroundContainer (0x0052): u32 containerGuid.
- TradeFailure (0x0207): u32 errorCode.
- AddToTrade (0x0200): (itemGuid, slotIndex).
- AcceptTrade (0x0202): u32 initiatorGuid.
- QueryItemManaResponse (0x0264): (itemGuid, f32 manaPercent).
- CharacterConfirmationRequest (0x0274): (type, contextId, otherGuid,
  string16L message) — server-driven modal confirmations.

All defensive: return null on truncated payloads rather than throwing,
matching the existing style. Caller can keep the dispatcher alive even
on malformed events.

Build green, tests unchanged (no new tests — these are simple passthroughs).

Ref: r08 §4 payloads for each opcode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:29:56 +02:00
Erik
d8c68c6648 feat(net): InventoryActions — stack merge/split + give + shortcut + poi recall
Outbound GameActions for the inventory/drag-drop UI and quickbar:

- StackableMerge (0x0054): u32 mergeFrom, u32 mergeTo, u32 amount.
  Combine two same-type stacks.
- StackableSplitToContainer (0x0055): u32 stack, u32 container,
  u32 placement, u32 amount. Drag a portion of a stack into a pack slot.
- StackableSplitTo3D (0x0056): u32 stack, u32 amount. Drop N items to
  the ground.
- StackableSplitToWield (0x019B): u32 stack, u32 equipLoc, u32 amount.
  Split off and immediately equip (e.g. split an arrow stack to
  missile-ammo slot).
- GiveObjectRequest (0x00CD): u32 target, u32 item, u32 amount. Give to
  NPC / other player.
- AddShortcut (0x019C): u32 slot, u32 objectType, u32 targetId.
  Pin an item / spell to a quickbar.
- RemoveShortcut (0x019D): u32 slot. Unpin.
- TeleToPoi (0x00B1): u32 poiId. Quest-driven recall.

Tests (8 new): byte-exact encoding of each action, including size
assertions so breaking changes surface immediately.

Build green, 190 Core.Net tests pass (up from 182).

Ref: r08 §3 inventory / shortcut rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:28:35 +02:00
Erik
fa266aaa03 feat(net): SocialActions — query / fellowship / channel / options outbound
Broad batch of GameActions for features the UI will wire to buttons +
hotkeys: /hp query, ping keepalive, fellowship full lifecycle,
character-options persist, chat channel subscribe/unsubscribe.

Wire layer:
- QueryHealth (0x01BF): u32 targetGuid — server replies UpdateHealth
  (0x01C0, already parsed by Phase F.1 dispatcher + routed to
  CombatState).
- PingRequest (0x01E9): u32 clientId — server echoes PingResponse
  (0x01EA) with matching id. Keepalive use.
- FellowshipCreate (0x00A2): string16L name + 2 u8 bools.
- FellowshipQuit (0x00A3): u8 disband.
- FellowshipDismiss (0x00A4) / FellowshipRecruit (0x00A5): u32 guid.
- FellowshipUpdate (0x00A6): u8 open.
- SetCharacterOptions (0x01A1): u32 options bitmap.
- AddChannel (0x0145) / RemoveChannel (0x0146): string16L channelName.

Tests (10 new): byte-exact wire encoding for each action.

Build green, 182 Core.Net tests pass (up from 172).

Ref: r08 §3 rows 0x01BF / 0x01E9 / 0x00A2-0x00A6 / 0x01A1 / 0x0145 / 0x0146.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:26:58 +02:00
Erik
a9f366718d feat(net): AppraiseInfoParser — ArmorProfile/CreatureProfile/WeaponProfile + enchantment bitfields
Completes the deferred-in-previous-commit profile blob deserializers.
The AppraiseInfo wire format has 10 flags; previous commit handled the
6 property tables + SpellBook; this adds the 7 remaining structured
blobs:

- ArmorProfile: 8× f32 per-damage-type protection values
  (Slashing / Piercing / Bludgeoning / Cold / Fire / Acid / Nether /
  Lightning).
- ArmorLevel: 9× i32 per-body-part AL
  (Head / Chest / Abdomen / UpperArm / LowerArm / Hand / UpperLeg /
  LowerLeg / Foot).
- WeaponProfile: 10 mixed fields — u32 DamageType / WeaponTime /
  WeaponSkill / Damage, f64 DamageVariance / DamageMod / WeaponLength /
  MaxVelocity / WeaponOffense, u32 MaxVelocityEstimated.
- CreatureProfile: flag-gated — always u32 Flags + Health + HealthMax,
  optional 10× u32 attributes + vitals (flag 0x08 = ShowAttributes),
  optional 2× u16 highlight/color (flag 0x01 = HasBuffsDebuffs).
- Enchantment bitfields (ArmorEnchantmentBitfield /
  WeaponEnchantmentBitfield / ResistEnchantmentBitfield): each 2× u16
  (highlight, color).

HookProfile (flag 0x200) still deferred — needs its own structure port.

Parsed record expanded to carry all these; callers that previously
consumed PropertyBundle + SpellBook keep working, new fields are
nullable record-struct payloads.

Tests (+6): ArmorProfile round-trip, ArmorLevels, WeaponProfile with
mixed primitives, CreatureProfile with + without attributes flag,
ArmorEnchantment bitfield.

Build green, 172 Core.Net tests pass (up from 166).

Ref: ACE AppraiseInfo.cs:735-778 (writer), ArmorProfile.cs / ArmorLevel.cs /
WeaponProfile.cs / CreatureProfile.cs (structure writers).
Ref: r08 §4 opcode 0x00C9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:24:35 +02:00
Erik
53f0110b89 fix(anim): remote-entity stop detection from position deltas
Root cause found from ACE source:
- Player_Tick.cs:368 — "the client will never send a 'client released
  forward' MoveToState in this scenario unfortunately"
- Server therefore can't broadcast a MotionCommand.Ready UpdateMotion
  when a remote player stops moving.
- Retail observer infers stopped state from position deltas going to
  zero, not from an explicit motion message.

Also found + fixed the UpdateMotion parser's 2-byte offset bug: ACE's
Align() pads based on absolute stream length (length=15 → 1 pad byte),
not relative-to-block. Previous parser assumed 3 pad bytes after the
MovementData header, which mis-aligned every subsequent field by 2.
After fix, stance/command/speed decode correctly for both server-
controlled NPCs (full stance 0x003D + cmd transitions) and remote
players (stance=0 meaning "no change" + per-axis commands).

OnLiveMotionUpdated rewrite: use SetCycle directly for sequencer
entities instead of routing through GetIdleCycle (which ignored
command when stance was 0). Preserve current style/motion when the
server omits a field ("no change" semantics). Reconstruct full
MotionCommand high byte from current motion or SubState mask.

Remote stop-detection: new _remoteLastMove dict tracks per-entity last
meaningful position + time. OnLivePositionUpdated updates only on
moves > 0.05m so the timestamp captures last actual movement.
TickAnimations checks every entity in a locomotion cycle; if their
last-move time is >400ms stale, swap sequencer to Ready. Excludes
player's own entity (driven by local input, not server observation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:18:34 +02:00
Erik
4d96156e05 feat(net): wire IdentifyObjectResponse into ItemRepository + Spellbook
GameEventWiring now registers a handler for
GameEventType.IdentifyObjectResponse (0x00C9) that:

1. Runs AppraiseInfoParser.TryParse to extract the full property bundle.
2. If the item is in the repository, merges the bundle into its
   Properties via ItemRepository.UpdateProperties (fires
   ItemPropertiesUpdated).
3. Merges any SpellBook entries into Spellbook.OnSpellLearned (caster
   weapons list their cast-on-use spells; PlayerDescription reuses the
   same container for the player's learned set).

Effect: when the player clicks "Appraise" on an item, the tooltip
panel can read full property detail from ItemInstance.Properties
immediately after the server replies.

Build + 628 tests still green. No new test file needed — existing
AppraiseInfoParser tests cover the parse path; GameEventWiring round-
trip tests cover the dispatch path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:22:31 +02:00