Moves the UDP receive onto a dedicated daemon thread that
continuously pulls raw datagrams from the kernel buffer and posts
them to a Channel<byte[]>. Tick() on the render thread drains the
channel instead of calling _net.TryReceive() directly. All decode,
fragment assembly, ISAAC crypto, event dispatch, and ack-sending
remain on the render thread — this is the minimal change that
prevents packet drops during render-thread stalls without the
complexity of moving decode/dispatch off-thread.
The net thread starts at the end of EnterWorld() after the handshake
is complete — during Connect() and EnterWorld(), PumpOnce() still
reads directly from the socket (the net thread isn't running yet).
Dispose() cancels the thread via CancellationToken, joins with a
2-second timeout, then disposes the socket.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When closing the acdream window, WorldSession.Dispose now sends
CharacterLogOff (game message 0xF653, no payload) followed by a bare
DISCONNECT control packet (header flag 0x8000) before closing the
UDP socket. This tells ACE to release the character lock immediately
instead of waiting for the ~60s network timeout — which was blocking
rapid iteration during testing since acdream now does a proper login
(Phase 4.8-4.10) and ACE holds the character in-world.
Pattern from references/holtburger/.../client/commands.rs lines
879-892 (Quit handler). Best-effort: if the socket is already dead,
the exception is eaten and Dispose finishes cleanup normally.
220 tests still green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User reported that even with the Phase 4.9 ack pump, acdream's
character still rendered to other clients as the purple loading haze.
Spent another round in holtburger's references and found two more
gaps in the post-EnterWorld handshake:
1. Server sends DddInterrogation (game opcode 0xF7E5) and waits for
the client to acknowledge dat-list versions. We never replied.
Build the canonical empty response (12 bytes: opcode + language=1
+ count=0 lists) and ship it as soon as DddInterrogation arrives.
2. LoginComplete was being sent immediately after CharacterEnterWorld
in Phase 4.8, which is too early — the server hasn't finished
creating the player object yet so it ignores LoginComplete and
the player stays in transition state. The correct trigger is the
server's PlayerCreate (0xF746) game message for our character;
that's when holtburger fires send_login_complete (see references/
holtburger/.../client/messages.rs::PlayerCreate handler).
Wired both into ProcessDatagram. Removed the unconditional
LoginComplete from the EnterWorld flow. Added a _loginCompleteSent
latch so re-PlayerCreate (e.g., across portal teleports) doesn't
re-fire LoginComplete during the same session.
Reference repo cited per the new CLAUDE.md guidance — holtburger is
the authoritative client-behavior reference. Should have looked there
sooner; this would have saved the Phase 4.8 false fix.
220 tests still green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause of the still-purple-haze symptom AND the ACE-side
"Network Timeout" drop after ~60s. acdream was never sending
acknowledgement packets back to the server, so the server's
reliability layer saw a one-way stream and eventually dropped the
session. During the 60s window the player rendered to other clients
as the stationary purple loading haze (AC's "this client is in
portal-space transition" indicator).
Pattern ported from
references/holtburger/crates/holtburger-session/src/session/
{send.rs::send_ack, receive.rs::finalize_ordered_server_packet}.
The proper holtburger pattern is per-packet acks, NOT a periodic
heartbeat: every received server packet with sequence > 0 and no
ACK_SEQUENCE flag of its own gets a bare control packet sent back
with:
PacketHeader {
Flags = ACK_SEQUENCE (0x4000),
Sequence = current_client_sequence (= last issued, no increment),
Id = session client id,
}
Body = u32 little-endian server sequence being acked
Acks are cleartext control packets (no EncryptedChecksum) and
re-use the most recently issued client sequence rather than
consuming a new one — they aren't part of the reliable stream the
server tracks for retransmits.
Wired into ProcessDatagram so both Tick (post-InWorld) and PumpOnce
(during Connect/EnterWorld) trigger acks on every received non-ack
server packet.
Also (per user request) upgrades the CLAUDE.md description of the
holtburger reference repo from "Rust AC client crate" to "almost-
complete Rust TUI AC client — the most authoritative reference for
client-side behavior in the project, look here FIRST for anything
WorldSession or message-builder related." This was the third time
in two days I would have saved hours by checking holtburger first
instead of guessing at the protocol from ACE alone.
220 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User reported that when they observed acdream's character through a
second AC client running on a different account, the character
rendered as a stationary purple haze (AC's "loading screen / portal
space" indicator) instead of a normal avatar. The character was
"in-world enough" to receive the CreateObject stream but never
"in-world enough" for the server to flip its first-enter-world flag,
push initial property updates / equipment overrides, or show the
character to other clients in the area.
Root cause: WorldSession.EnterWorld stopped after sending
CharacterEnterWorld (0xF657). The handshake is supposed to continue
with one more message — a GameAction(LoginComplete) — that ACE's
GameActionLoginComplete handler interprets as "client has exited
portal space, mark FirstEnterWorldDone, push property updates,
make the character visible to others."
Wire layout (confirmed via
references/ACE/Source/ACE.Server/Network/GameAction/GameActionPacket.cs
and .../Actions/GameActionLoginComplete.cs):
u32 game-message opcode = 0xF7B1 (GameAction)
u32 sequence = 0 (ACE ignores; TODO comment in source)
u32 GameActionType opc = 0x000000A1 (LoginComplete)
Send happens immediately after CharacterEnterWorld and just before
flipping the WorldSession state to InWorld. acdream has no portal-
space transition animation, so we can claim "loading complete" the
moment we've sent the EnterWorld message — the dat-side world is
already loaded by then.
1 new test (97 Core.Net total). 220 tests green overall.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Companion to the Phase 6.6 UpdateMotion parser. Without this, every
server-spawned entity stays frozen at its CreateObject origin forever
— NPCs don't patrol, creatures don't hunt, other players don't walk
past. UpdatePosition is the per-entity position delta the server sends
on every movement tick.
The wire format is straightforward but fiddly:
u32 opcode | u32 guid | u32 flags | u32 cellId | 3xf32 pos
(0..4) conditional f32 rotation components, present iff the
corresponding OrientationHasNo* flag is CLEAR
optional 3xf32 velocity iff HasVelocity
optional u32 placementId iff HasPlacementID
four u16 sequence numbers (consumed but not used)
Layout ported from references/ACE/Source/ACE.Server/Network/Structure/
PositionPack.cs::Write and ACE.Entity/Enum/PositionFlags.cs.
WorldSession dispatches PositionUpdated(guid, position, velocity) on
a successful parse. GameWindow wiring (guid → WorldEntity lookup and
transform swap) is deferred to the same follow-up commit that lands
Phase 6.6 wiring, after the in-flight Phase 9.1 translucent-pass work
merges so we don't step on GameWindow.cs edits.
96 Core.Net tests (was 89, +7 for UpdatePosition coverage).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server sends UpdateMotion whenever an entity's motion state changes:
NPCs starting a walk cycle, creatures switching to a combat stance,
doors opening, a player waving, etc. Phase 6.1-6.4 already handles
rendering different (stance, forward-command) pairs for the INITIAL
CreateObject, but without this message NPCs freeze in whatever pose
they spawned with and never transition to walking/fighting.
Added UpdateMotion.TryParse with the same ServerMotionState the
CreateObject path uses, reached via a slightly different outer
layout (guid + instance seq + header'd MovementData; the MovementData
starts with the 8-byte sequence/autonomous header this time rather
than being preceded by a length field). Only the (stance, forward-
command) pair is extracted — same subset CreateObject grabs.
WorldSession dispatches MotionUpdated(guid, state) when a 0xF74C
body parses successfully. The App-side wiring (guid→entity lookup
and AnimatedEntity cycle swap) is intentionally deferred to a
separate commit because it touches GameWindow which is currently
being edited by the Phase 9.1 translucent-pass work.
89 Core.Net tests (was 83, +6 for UpdateMotion coverage).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Foundry's drudge statue Setup (0x020007DD) has DefaultMotionTable=0,
so MotionResolver returned null and the renderer fell back to
PlacementFrames[Default] — an upright pose, which is wrong. The retail
crouched/aggressive pose comes from a per-instance motion table the
server attaches via PhysicsDescriptionFlag.MTable (confirmed live as
0x090000DA for the statue).
CreateObject.TryParse was already walking the MTable field but
discarding the value. Now it captures it as Parsed.MotionTableId and
WorldSession.EntitySpawn forwards it. GameWindow passes it as the
motionTableIdOverride to MotionResolver.GetIdleFrame, so the cycle
lookup uses the server-supplied table when the dat-side default is
empty. With this in place the drudge resolves a real cycle and
renders in the correct crouched pose.
Trimmed the heavy STATUE motion-table dump diagnostics now that the
mechanism is verified; left a one-line summary so future regressions
remain debuggable. 160 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CreateObject's MovementData was being skipped past, so the renderer
always fell back to the MotionTable's default style/substate. That's
correct for most NPCs and characters but wrong for entities the
server explicitly puts into a non-default stance — most visibly the
Foundry's Nullified Statue of a Drudge, which the server sends with
a combat stance + Crouch ForwardCommand override and which therefore
rendered as an upright drudge instead of the aggressive crouched
statue you see on the retail client.
CreateObject.TryParse now extracts ServerMotionState (Stance +
optional ForwardCommand) from the inner MovementData. The header=false
layout was confirmed via ACE/.../WorldObject_Networking.cs:326 plus
MovementData.cs::Write and InterpretedMotionState.cs::Write. Only the
two fields the resolver needs are read; remaining InterpretedMotionState
bytes are skipped via the outer length so we don't have to handle
alignment of fields we don't care about.
MotionResolver.GetIdleFrame now takes optional stanceOverride and
commandOverride. Resolution priority is server-stance+command →
server-stance + style-default substate → MotionTable default. If the
composed cycle key doesn't resolve we fall back to the table default
rather than returning null, so a partial server override never makes
the entity worse than Phase 6.1.
160 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Nullified Statue of a Drudge renders correctly in color/shape
after Phase 5b's SubPalette fix, but the user reported it's rendering
at base drudge size when it should be larger. AC statues use the
PhysicsDescriptionFlag.ObjScale field to scale the base mesh; my
parser was consuming-and-skipping those 4 bytes.
Changes:
- CreateObject.TryParse: extract the u32 float from the ObjScale
field instead of advancing past it. Declaration moved to the top
of the method alongside other accumulators so the PartialResult
local function at the bottom can reference it for the truncation
fallback path. Same structural change for position and setupTableId
since PartialResult already needed them too.
- CreateObject.Parsed gains ObjScale (float?).
- WorldSession.EntitySpawn gains ObjScale; propagated through the
fire site in ProcessDatagram.
- GameWindow.OnLiveEntitySpawned bakes a scale matrix into every
MeshRef's PartTransform when ObjScale != 1.0, following the same
pattern the offline scenery hydration already uses. No change to
WorldEntity or StaticMeshRenderer — the scale is absorbed into the
per-part transform the renderer already multiplies through.
Tests: 77 core + 83 net = 160, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Finishes the TextureChange half of ObjDesc. Characters' clothing now
renders with correct per-part textures (user-verified "looks good"
after previous "partial coverage" / "wrong clothes"). The Nullified
Statue still looks like a flesh-colored drudge because the statue's
color comes from SubPalettes (palette-indexed texture recoloring),
which is the remaining major Phase 5 piece.
The first attempt at TextureChange application was silently broken by
an ID-type mismatch: the server encodes OldTexture/NewTexture as
SurfaceTexture (0x05XXXXXX) ids, but my sub-meshes are keyed by
Surface (0x08XXXXXX) ids. The override dict was keyed by one type
and looked up by the other, so TryGetValue never hit and no override
actually applied.
Diagnosed via Phase 1 systematic debugging with resolve-level logging:
live: spawn +Acdream texChanges=20
live: texChange part=0 old=0x05000BB0 new=0x0500025D
...
live: resolve part=0 surface=0x08000519 origTex=0x05000BB0 [MATCH]
live: resolve part=0 surface=0x0800051C origTex=0x05000CBE [MATCH]
... 10/10 lines [MATCH]
The [MATCH] lines proved the server's OldTexture IS reachable via a
Surface→OrigTextureId lookup, just needed keying by the right value.
Fix:
- TextureCache.GetOrUploadWithOrigTextureOverride(surfaceId, origTexOverride):
loads the base Surface dat for its color/flags/palette, but
substitutes the override SurfaceTexture id in the decode chain.
Caches under a (surfaceId, origTexOverride) composite key.
- MeshRef.SurfaceOverrides is now Dictionary<uint, uint> keyed by
Surface id, value = replacement OrigTextureId. Null means no
overrides.
- GameWindow.OnLiveEntitySpawned now does TWO passes when texture
changes are present:
1. Group the raw server changes by PartIndex into (oldOrigTex →
newOrigTex) dicts
2. For each affected part's post-animPartChange GfxObj, iterate
its Surfaces list, resolve each Surface → OrigTextureId, and
if that matches a raw change's oldOrigTex, write an entry
Surface id → newOrigTex into the final override map
- StaticMeshRenderer.Draw: when sub-mesh surface id has an override,
call GetOrUploadWithOrigTextureOverride instead of GetOrUpload.
Verified live: +Acdream's clothing renders correctly, NPCs are
"much better" (characters previously naked are now dressed). Statue
has the full mechanical pipeline working (resolve diagnostic shows
2/2 Surfaces [MATCH] for the statue's override dict) but its visible
color comes from the separate SubPalette overlay that isn't wired yet.
Also added a statue-targeted diagnostic block that dumps its full
ObjDesc contents (texChanges + subPalettes + animPartChanges) by
name match, which is how I traced the Nullified Statue of a Drudge's
specific ObjDesc. Lives under `if (isStatue && ...)` so normal logins
aren't spammed.
Cross-referenced against two new references this session:
* references/Chorizite.ACProtocol (cloned from github.com/Chorizite/
Chorizite.ACProtocol.git on user's suggestion) — confirms the
ObjDesc field order and PackedDword-of-known-type convention.
* references/WorldBuilder/... (already in repo) — confirms the
Surface→OrigTexture→SurfaceTexture→RenderSurface chain and the
P8/INDEX16 palette decode path.
Tests: 77 core + 83 net = 160, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
statue identified (Phase 4.7h/i/j/k/l)
Makes three big improvements to the CreateObject decode path:
1. Extract AnimPartChanges from the ModelData section instead of
skipping them. Each change is (PartIndex, NewModelId); the server
uses these to replace base Setup parts with armor/clothing/statue
meshes. The player character has ~34 of them on a normal login.
2. Flow AnimPartChanges through WorldSession.EntitySpawn into
GameWindow.OnLiveEntitySpawned, which now patches the flattened
Setup's part list BEFORE uploading GfxObjs. Patching is a simple
"parts[change.PartIndex] = new MeshRef(change.NewModelId, oldTransform)"
keeping the base Setup's placement transform but swapping the mesh.
3. Read the WeenieHeader Name (String16L) that follows the PhysicsData
section. Required walking past every remaining physics flag (Parent,
Children, ObjScale, Friction, Elasticity, Translucency, Velocity,
Acceleration, Omega, DefaultScript, DefaultScriptIntensity) plus the
9 sequence timestamps (2 bytes each) plus 4-byte alignment. The
Name field is then the second thing in the WeenieHeader after
u32 weenieFlags.
Critical bug fix in the same commit: ACE's WritePackedDwordOfKnownType
STRIPS the known-type high-byte prefix (e.g. 0x01000000 for GfxObj ids)
before writing the PackedDword. The first version of AnimPartChange
decoding called plain ReadPackedDword, so it got 0x0000XXXX instead of
0x0100XXXX and every GfxObj dat lookup silently failed — the drop
counter showed 19+ noMeshRef drops including +Acdream himself.
Added ReadPackedDwordOfKnownType that ORs the knownType bit back in
on read (with zero preserved as the "no value" sentinel). After the
fix, noMeshRef drops = 0 across a full login.
LIVE RUN after all three changes:
live: spawn guid=0x5000000A name="+Acdream" setup=0x02000001
pos=(58.5,156.2,66.0)@0xA9B40017 animParts=34
live: spawn guid=0x7A9B4035 name="Holtburg" setup=0x020006EF
pos=(94.6,156.0,66.0)@0xA9B4001F animParts=0
live: spawn guid=0x7A9B4000 name="Door" setup=0x020019FF
pos=(84.1,131.5,66.1)@0xA9B40100 animParts=0
live: spawn guid=0x7A9B4001 name="Chest" setup=0x0200007C
pos=(78.1,136.9,69.5)@0xA9B40105 animParts=0
live: spawn guid=0x7A9B4036 name="Well" setup=0x02000180
pos=(90.1,157.8,66.0)@0xA9B4001F animParts=0
live: spawn guid=0x800005FD name="Wide Breeches" setup=0x02000210
pos=no-pos animParts=1
live: spawn guid=0x800005FC name="Smock" setup=0x020000D4
pos=no-pos animParts=1
live: spawn guid=0x800005FE name="Shoes" setup=0x020000DE
pos=no-pos animParts=1
live: spawn guid=0x80000697 name="Facility Hub Portal Gem"
setup=0x02000921 pos=no-pos animParts=0
live: spawn guid=0x7A9B404B name="Nullified Statue of a Drudge"
setup=0x020007DD pos=(65.3,156.8,72.8)@0xA9B40017 animParts=1
summary recv=60 hydrated=43 drops: noPos=17 noSetup=0
setupMissing=0 noMesh=0
The statue's exact data is now known and the hydration path runs
without errors. The user's "look at the Name field in the CreateObject
body" insight turned this from an unbounded visual hunt into a targeted
grep of ~60 log lines.
Tests: 77 core + 83 net = 160 passing (offline suite unchanged).
Live handshake + enter-world tests still pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The end-to-end pipeline. acdream can now connect to a live ACE server,
complete the full handshake + character-select + enter-world flow, and
stream CreateObject messages straight into the existing IGameState and
static mesh renderer. Gated behind ACDREAM_LIVE=1 so the default
offline run path is untouched.
Added:
- AcDream.Core.Net.WorldSession: high-level session type that owns a
NetClient, drives the 3-leg handshake, parses CharacterList, sends
CharacterEnterWorldRequest + CharacterEnterWorld, and converts the
post-login fragment stream into C# events. State machine:
Disconnected → Handshaking → InCharacterSelect → EnteringWorld →
InWorld (or Failed). Public API:
* Connect(user, pass) — blocks until CharacterList received
* EnterWorld(user, characterIndex) — blocks until ServerReady
* Tick() — non-blocking, call per game-loop frame
* event EntitySpawned
* event StateChanged
* Characters property (populated after Connect)
- NetClient.TryReceive: non-blocking variant that returns immediately
with null if the kernel buffer is empty. Enables draining packets
per frame from the main thread without stalling.
- GameWindow live-mode hookup:
* AcDream.Core.Net project reference
* TryStartLiveSession() called after dat hydration, gated behind
ACDREAM_LIVE=1 + ACDREAM_TEST_USER/ACDREAM_TEST_PASS env vars
* Subscribes EntitySpawned to OnLiveEntitySpawned
* Calls Connect() then EnterWorld(0) synchronously on startup
* OnLiveEntitySpawned hydrates mesh refs from the Setup dat
(same SetupMesh.Flatten + GfxObjMesh.Build + StaticMesh.EnsureUploaded
path used by scenery), publishes a WorldEntitySnapshot via
_worldGameState.Add + _worldEvents.FireEntitySpawned, and
appends to _entities so the next frame picks it up
* OnUpdate calls _liveSession?.Tick() each frame
* OnClosing disposes the session
* Position translation: server sends (LandblockId, local XYZ +
quaternion); we map landblock to world origin relative to the
rendered 3x3 center, add local XYZ, translate AC's (W,X,Y,Z)
quaternion wire order to System.Numerics.Quaternion (X,Y,Z,W)
LIVE RUN OUTPUT (ACDREAM_LIVE=1 against localhost ACE, testaccount):
[dats loaded, 1133 static entities hydrated]
live: connecting to 127.0.0.1:9000 as testaccount
live: entering world as 0x5000000A +Acdream
live: in world — CreateObject stream active (so far: 0 received, 0 hydrated)
live: spawned guid=0x5000000A setup=0x02000001 world=(104.9,15.1,94.0)
live: spawned guid=0x7A9B4013 setup=0x0200007C world=(135.7,9.9,97.0)
live: spawned guid=0x7A9B4014 setup=0x0200007C world=(132.5,9.9,97.0)
live: spawned guid=0x7A9B4015 setup=0x020019FF world=(132.6,17.1,94.1)
live: spawned guid=0x7A9B4016 setup=0x020019FF world=(136.3,5.2,94.1)
live: spawned guid=0x7A9B4017 setup=0x020019FF world=(104.1,31.0,94.1)
live: spawned guid=0x7A9B4037 setup=0x02000975 world=(109.7,33.0,95.0)
live: spawned guid=0x7A9B4018 setup=0x020019FF world=(110.9,31.0,94.1)
live: spawned guid=0x7A9B4019 setup=0x020019FF world=(107.5,31.5,94.1)
live: spawned guid=0x7A9B403B setup=0x02000B8E world=(150.5,17.9,94.0)
live: (suppressing further spawn logs)
First line: +Acdream himself. setup=0x02000001 is ACE's default humanoid
player mesh. world coords match Holtburg (landblock 0xA9B4 local
space). Subsequent spawns are weenies at various setup ids — likely
the foundry statue, street lamps, drums, etc. The 0x7A9B4xxx GUID
pattern is ACE's convention: scenery-type (0x7) + landblock (0xA9B4) +
per-object index.
All spawns flow through the SAME SetupMesh/GfxObjMesh/StaticMeshRenderer
pipeline used by scenery and interiors today. The plugin system's
EntitySpawned event fires on every new entity, so plugins can see
them without any networking awareness.
Tests: 160 passing offline (77 core + 83 net). The live handshake and
enter-world tests are gated and still pass when ACDREAM_LIVE=1.
User visual verification is the final acceptance for Phase 4. Run
with ACDREAM_DAT_DIR + ACDREAM_LIVE=1 + ACDREAM_TEST_USER=testaccount
+ ACDREAM_TEST_PASS=testpassword and look for +Acdream's model + the
foundry statue standing on top of the Holtburg foundry.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>