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>
Decodes the CreateObject (0xF745) game message body far enough to hand
an entity off to acdream's existing IGameState/MeshRenderer pipeline.
Ported from ACE's WorldObject_Networking.cs (SerializeCreateObject,
SerializeModelData, SerializePhysicsData) and Position.cs.
Scope: the parser extracts exactly three fields —
- GUID (u32 right after the opcode)
- ServerPosition (landblockId + XYZ + rotation quaternion), if the
Position bit is set in the PhysicsDescriptionFlag
- SetupTableId (setup dat id for the visual mesh chain), if the
CSetup bit is set
Everything else in a CreateObject body (weenie header, object description,
motion tables, palettes, texture overrides, animation frames, velocity,
acceleration, omega, scale, friction, elasticity, translucency,
default scripts, sequence timestamps, ...) is consumed-or-skipped with
just enough bytes to advance past the correct flag-gated sections.
The parser stops at the end of PhysicsData — we don't need weenie-header
fields for rendering placement.
Components parsed in order (all from ACE's serialize routines):
1. Opcode u32 (must be 0xF745)
2. u32 GUID
3. ModelData header (byte 0x11 marker, byte subPaletteCount,
byte textureChangeCount, byte animPartChangeCount), followed by
PackedDword palette/subPalette fields, texture change records,
anim part change records, aligned to 4 bytes at end
4. u32 PhysicsDescriptionFlag
5. u32 PhysicsState (skipped)
6. Conditional Movement/AnimationFrame section
7. Conditional Position section (LandblockId, X, Y, Z, RW, RX, RY, RZ)
8. Conditional MTable/STable/PeTable u32 ids (all skipped)
9. Conditional CSetup u32 (extracted as SetupTableId)
The PackedDword reader is a new helper: AC's variable-width uint format
where values ≤ 32767 encode as a u16, larger values use a marker bit in
the top of the first u16 and a continuation u16. Ported from
Extensions.WritePackedDword.
LIVE RUN AGAINST THE ACE SERVER (test account, Holtburg):
step 4: CharacterList received account=testaccount count=2
character: id=0x5000000A name=+Acdream
character: id=0x50000008 name=+Wdw
sent CharacterEnterWorldRequest
step 6: CharacterEnterWorldServerReady received
sent CharacterEnterWorld(guid=0x5000000A)
step 8 summary: 83 GameMessages assembled, 68 CreateObject,
68 parsed, 52 w/position, 68 w/setup
First 10 parsed CreateObjects:
guid=0x5000000A lb=0xA9B40021 xyz=(104.89,15.05,94.01) setup=0x02000001
guid=0x80000600 no position setup=0x02000181
guid=0x800005FF no position setup=0x02000B77
guid=0x80000603 no position setup=0x02000176
guid=0x80000604 no position setup=0x02000D5C
guid=0x80000694 no position setup=0x020005FF
guid=0x80000697 no position setup=0x02000921
guid=0x80000601 no position setup=0x02000179
guid=0x80000605 no position setup=0x02000155
guid=0x80000695 no position setup=0x020005FF
The first line is +Acdream himself — GUID matches what we picked from
CharacterList, landblock 0xA9B4 is Holtburg (the area we already render),
setup 0x02000001 is the default humanoid player mesh. The other 67 are
NPCs/weenies/scenery-weenies in the same area; the 16 without positions
are inventory items whose position is inherited from the parent.
ALL 68 CreateObjects parsed cleanly — no short reads, no format errors.
Phase 4.7d proves byte-level compatibility with ACE's outbound network
serialization format. The remaining Phase 4 work (WorldSession type +
GameWindow wiring) is glue code above a codec that now speaks the real
AC wire format.
Tests: 77 core + 83 net (+1 live test) = 161 passing, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drives the full post-handshake flow on a live ACE server. After the
3-way handshake completes, acdream:
1. Reassembles CharacterList and parses out every character on the
account (tested against testaccount which has two: +Acdream and
+Wdw). Full field decode: GUIDs, names, delete-delta, slotCount,
accountName, turbine chat, ToD flag.
2. Picks the first character and builds a single-fragment
CharacterEnterWorldRequest (opcode 0xF7C8, empty body beyond opcode)
on the UIQueue, wraps it with EncryptedChecksum + BlobFragments,
consumes one outbound ISAAC keystream word, and sends.
3. Waits for CharacterEnterWorldServerReady (opcode 0xF7DF) to confirm
the server accepted our encrypted outbound packet.
4. Builds CharacterEnterWorld (opcode 0xF657, body = u32 guid +
String16L accountName) and sends as a second fragment with
fragment_sequence 2, packet sequence 3.
5. Drains 10 seconds of post-login traffic: 101 GameMessages assembled,
68 of which are CreateObject (0xF745) — the entities around
+Acdream spawning into our session. Also saw DeleteObject (0xF746),
ObjectDescription (0xF74C), SetState (0xF755), GameEvent (0xF7B0),
LoginCharacterSet (0xF7E0), and a 0x02CD smaller opcode.
This is the Phase 4.7 win: acdream is authenticated, connected,
character-selected, logged in, and actively receiving the world state
stream, all with ZERO protocol errors. Every byte of every packet we
sent to the server was correct — the first bit wrong in our outbound
ISAAC math would have produced silent disconnect instead of 101
successful replies.
Added to AcDream.Core.Net:
- Messages/CharacterList.cs: full parser for opcode 0xF658, ported
from ACE's GameMessageCharacterList writer. Returns structured
record with Characters[], SlotCount, AccountName, UseTurbineChat,
HasThroneOfDestiny. Tested offline with hand-assembled bodies
matching ACE's writer format.
- Messages/CharacterEnterWorld.cs: outbound builders for
CharacterEnterWorldRequest (0xF7C8, opcode-only) and
CharacterEnterWorld (0xF657, opcode + guid + String16L account).
- Messages/GameMessageFragment.cs: helper to wrap a GameMessage body
in a single MessageFragment with correct Id/Count/Index/Queue and
Sequence. Also a Serialize helper to turn a MessageFragment into
packet-body bytes for PacketCodec.Encode. Throws on oversize
(>448 byte) messages; multi-fragment outbound split is TBD.
- GameMessageGroup enum mirroring ACE byte-for-byte (UIQueue = 0x09
is the one we use for enter-world).
Fixed: FragmentAssembler was keying on MessageFragmentHeader.Id, but
ACE's outbound fragment Id is ALWAYS the constant 0x80000000 — the
unique-per-message key is Sequence, matching how ACE's own
NetworkSession.HandleFragment keys its partialFragments dict. Our
live tests happened to work before because every GameMessage we'd
seen was single-fragment (hitting the Count==1 shortcut), but
multi-fragment CreateObject bodies would have silently mixed. Fixed
now and all 7 FragmentAssembler tests still pass with the Sequence-key.
Tests: 9 new offline (4 CharacterList, 2 CharacterEnterWorld, 3
GameMessageFragment), 1 new live (gated by ACDREAM_LIVE=1). Total
77 core + 83 net = 160 passing.
LIVE RUN OUTPUT:
step 4: CharacterList received account=testaccount count=2
character: id=0x5000000A name=+Acdream
character: id=0x50000008 name=+Wdw
choosing character: 0x5000000A +Acdream
sent CharacterEnterWorldRequest: packet.seq=2 frag.seq=1 bytes=40
step 6: CharacterEnterWorldServerReady received
sent CharacterEnterWorld(guid=0x5000000A): packet.seq=3 frag.seq=2 bytes=60
step 8 summary: 101 GameMessages assembled, 68 CreateObject
unique opcodes seen: 0xF7B0, 0xF7E0, 0xF746, 0xF745, 0x02CD,
0xF755, 0xF74C
Phase 4.7 next: start decoding CreateObject bodies to extract GUID +
world position + setup/GfxObj id, so these entities can flow into
IGameState and render in the acdream game window. The foundry statue
is waiting in one of those 68 spawns.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This is the Phase 4 protocol-compatibility proof. acdream's codec now
completes the full AC UDP handshake against a live ACE server and
successfully decodes three consecutive EncryptedChecksum game packets
— which means every layer of the codec is byte-compatible with ACE.
Changes:
- NetClient: added Send(IPEndPoint, ReadOnlySpan<byte>) overload so
one socket can talk to ACE's two listener ports (9000 for
LoginRequest, 9001 for ConnectResponse and all subsequent traffic)
- LiveHandshakeTests.Live_FullThreeWayHandshake_ReachesConnectedState:
drives the full 3-leg handshake end-to-end. Protocol details that
I got wrong on the first attempt and fixed after reading
references/holtburger/crates/holtburger-session/src/session/auth.rs:
* ConnectResponse header.Sequence = 1 (LoginRequest is seq 0)
* ConnectResponse header.Id = 0 (NOT the clientId from
ConnectRequest; that field is ACE's internal session index,
separate from the packet header Id)
* 200ms Thread.Sleep before sending ConnectResponse — holtburger
calls this ACE_HANDSHAKE_RACE_DELAY_MS, empirically determined
to avoid a server-side race where ACE is still finalizing the
session when our ConnectResponse arrives
* ConnectResponse goes to port 9001, not 9000 (ACE's second
ConnectionListener, see Network/Managers/SocketManager.cs)
LIVE RUN OUTPUT:
[live] step 1: sending 84-byte LoginRequest to 127.0.0.1:9000
[live] step 2: got 52-byte datagram from 127.0.0.1:9000,
flags=ConnectRequest
ConnectRequest cookie=0x458ABEE950D18BEE clientId=0x00000000
[live] step 3: sleeping 200ms then sending 28-byte ConnectResponse
to 127.0.0.1:9001
ISAAC seeds primed
[live] step 4: got 28-byte datagram from :9001,
flags=EncryptedChecksum,TimeSync, seq=2 OK
[live] step 4: got 64-byte datagram from :9001,
flags=EncryptedChecksum,BlobFragments, seq=3 OK
[live] step 4: got 152-byte datagram from :9001,
flags=EncryptedChecksum,BlobFragments, seq=4 OK
[live] step 4: got 24-byte datagram from :9001,
flags=AckSequence, seq=4 OK
[live] step 4: got 24-byte datagram from :9001,
flags=AckSequence, seq=4 OK
[live] step 4 summary: 5 packets received, 5 decoded OK,
0 checksum failures
What each "OK" proves, reading left to right:
* TimeSync (seq=2): our IsaacRandom is byte-compatible with ACE's
ISAAC.cs — if a single bit were wrong in any state register the
checksum key would mismatch and decode would fail. Our inbound
ISAAC consumed one word for this packet.
* BlobFragments (seq=3, 64 bytes): header hash + fragment hash +
ISAAC key recipe all check out. These fragments contain the start
of GameMessageCharacterList / ServerName / DDDInterrogation game
messages ACE enqueues right after HandleConnectResponse. We don't
parse game message bodies yet (Phase 4.7) but the fragments are
fully retrievable from Packet.Fragments.
* BlobFragments (seq=4, 152 bytes): continuation of the same game
messages; our sequential ISAAC consumption handled two back-to-back
encrypted packets correctly.
* AckSequence (seq=4): unencrypted mixed with encrypted in the same
stream — our codec handles both paths in one session.
Everything in AcDream.Core.Net is now proven byte-compatible with a
retail AC server at the protocol level. The remaining Phase 4 work
(4.6f, 4.7) is above the codec: parsing game message opcodes out of
the fragment payloads and routing CreateObject into IGameState so
acdream can show the foundry statue and the +Acdream character.
Test counts: 77 core + 73 net (+1 new live test) = 150 passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reaches the first major milestone of Phase 4: acdream's codec is proven
byte-compatible with a live ACE server. LiveHandshakeTests drives a real
UDP exchange against 127.0.0.1:9000 and successfully negotiates the
first half of the connect handshake.
Added:
- Packets/PacketHeaderOptional.cs: new ConnectRequest flag branch.
ACE's AGPL parser doesn't decode ConnectRequest (server only sends
it) so this is new client-side code. Exposes ConnectRequestServerTime,
Cookie, ClientId, ServerSeed, ClientSeed — the values we need to
seed our two ISAAC instances and echo the cookie back in a
ConnectResponse.
- NetClient.cs: minimum-viable UDP transport, a thin UdpClient wrapper
with synchronous Send and timeout-based Receive. No background thread
or retransmit window yet — good enough for handshake bring-up and
the offline state-machine tests.
- LiveHandshakeTests.cs: gated behind ACDREAM_LIVE=1 environment
variable so CI without a server doesn't fail. Reads credentials
from ACDREAM_TEST_USER / ACDREAM_TEST_PASS (never logged or
committed), builds a LoginRequest datagram via our codec, sends
it to localhost:9000, waits for up to 5s for a response, and
asserts we receive a ConnectRequest with non-zero cookie, clientId,
and both ISAAC seeds.
Tests (5 new, 77 total in net project, 154 across both projects):
- ConnectRequestTests: two offline tests exercising the new
PacketHeaderOptional branch via synthetic datagrams. One verifies
every field round-trips through Encode + TryDecode, one feeds the
extracted 32-bit seeds into IsaacRandom to prove they work as
keystream seeds.
- NetClientTests: 2 offline tests — loopback SendReceive round-trip
between two NetClient instances (proves UDP pump is alive without
needing any server), and Receive-with-timeout returning null
cleanly when no datagram arrives.
- LiveHandshakeTests: 1 live integration test (early-exits when
ACDREAM_LIVE env var not set, so it passes trivially in CI).
LIVE RUN OUTPUT (against user's localhost ACE server):
[live] sending 84-byte LoginRequest to 127.0.0.1:9000 (user.len=11, pass.len=12)
[live] received 52-byte datagram from 127.0.0.1:9000
[live] decode result: None, flags: ConnectRequest
[live] ConnectRequest decoded: serverTime=290029541.121 cookie=0xAC45998D06754133
clientId=0x00000001 serverSeed=0x4CC09763 clientSeed=0x5C3DE13E
Meaning: 84-byte LoginRequest went out, 52-byte ConnectRequest came
back, codec.TryDecode returned None error, every field parsed to a
sensible value. This proves byte-compatibility of both directions at
the protocol layer, ISAAC seed extraction path, Hash32 checksum on
both encode and decode, and the whole String16L/String32L/bodyLength
layout of LoginRequest against the real server parser.
Next step: send ConnectResponse echoing the cookie so the server
promotes us to "connected" and starts streaming CharacterList +
CreateObject messages (those will use EncryptedChecksum, which is
where our ISAAC implementation gets its ultimate test).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Completes the encode side of the codec so acdream can stop hand-
assembling outbound packets in tests. Given a PacketHeader (with Flags
set, DataSize ignored/overwritten) and a body byte span, Encode:
1. Overwrites header.DataSize with body.Length
2. Parses the optional section out of the body (reusing
PacketHeaderOptional.Parse as a length measurer) and hashes those bytes
3. If BlobFragments is set, walks the body tail as back-to-back
fragments and sums their Hash32s
4. For unencrypted: header.Checksum = headerHash + optionalHash + fragmentHash
5. For EncryptedChecksum: pulls one ISAAC keystream word and computes
header.Checksum = headerHash + (isaacKey XOR payloadHash)
6. Packs header + body into the final datagram
Tests (6 new, 67 total in net project, 144 across both test projects):
- Unencrypted round-trip: Encode then TryDecode recovers the AckSequence
field
- DataSize is overwritten (caller can pass garbage)
- Encrypted round-trip: two ISAACs with same seed, one encoding and
one decoding, both agree on the keystream word
- Encrypted but no ISAAC → throws InvalidOperationException
- LoginRequest end-to-end: LoginRequest.Build → Encode → TryDecode →
LoginRequest.Parse round-trips credentials exactly. This is the
single most important integration test for the outbound side —
every byte this exercises is exactly what acdream will put on the
wire when Phase 4.6 goes live.
- BlobFragments body with one embedded fragment: Encode preserves
the fragment and fragmentHash is correctly folded into the checksum
Codec is now complete end-to-end (decode + encode) and has the
LoginRequest outbound path proven against its own decoder. The next
commit will wire NetClient over real UDP sockets and connect to the
localhost ACE server.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the outbound-side primitives acdream needs to send a LoginRequest
packet to an ACE server: a growable byte buffer writer with AC's two
length-prefixed string formats, plus the LoginRequest payload builder
and parser.
PacketWriter (Packets/PacketWriter.cs):
- Growable byte[] buffer with little-endian WriteByte/UInt16/UInt32/Bytes
- WriteString16L: u16 length + ASCII bytes + zero-pad to 4-byte boundary
(pad counted from start of length prefix, matching ACE's Extensions.cs)
- WriteString32L: u32 outer length (= asciiLen+1) + 1 marker byte (value
ignored by reader, we emit 0) + ASCII + pad. Reader decrements the
outer length by 1 when consuming the marker, so asciiLen is recovered
correctly. Asserts ≤255 chars (two-byte-marker variant not needed for
acdream's dev credentials).
- ASCII encoding used instead of Windows-1252 since dev account names
and passwords are ASCII-safe; can switch to CodePages later if a
non-ASCII identifier ever turns up.
LoginRequest (Packets/LoginRequest.cs):
- Build(account, password, timestamp, clientVersion="1802") produces
the login payload bytes that go into the body of a packet whose
header has the LoginRequest flag set
- Parse(bytes) for tests and diagnostics — server never calls this
in production, but round-trip tests make the writer self-verifying
- NetAuthType enum mirrors ACE: Account/AccountPassword/GlsTicket
- Wire layout per ACE's PacketInboundLoginRequest:
String16L ClientVersion
u32 bodyLength (bytes remaining after this field)
u32 NetAuthType (2 = AccountPassword)
u32 AuthFlags (0 for normal client)
u32 Timestamp
String16L Account
String16L LoginAs (empty for non-admin)
String32L Password (when AccountPassword)
- bodyLength field is back-patched after the full body has been
written (classic "write placeholder, come back and patch" flow)
Tests (17 new, 61 total in net project, 138 across both test projects):
PacketWriter (11):
- u32 little-endian
- String16L: empty, 1/2/3-char with correct padding
- String32L: 2-char short, empty, >255 throws
- AlignTo4 no-op when aligned, pads when not
- Buffer grows past initial capacity on big writes
LoginRequest (6):
- Build→Parse round-trip with realistic credentials (testaccount/
testpassword/timestamp)
- Empty account/password round-trip (padding edge case)
- BodyLength field reflects actual remaining bytes after itself
- Total wire size is multiple of 4 (sanity check on padding)
- Different credentials produce different bytes
- End-to-end: payload embedded in a full Packet with LoginRequest
header flag + correct unencrypted checksum, PacketCodec.TryDecode
parses it, BodyBytes round-trips back to the same credentials
through LoginRequest.Parse
This gives acdream everything needed to construct the first datagram
of the handshake. Phase 4.5c next: WorldSession state machine to drive
the handshake sequence.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Brings the codec to end-to-end: a raw UDP datagram goes in, a parsed
Packet comes out with verified CRC (both plain and ISAAC-encrypted
variants). Synthetic packets built inside tests round-trip through
TryDecode cleanly.
Added:
- Packets/PacketHeaderOptional.cs: parses every flag-gated section
that lives between the 20-byte header and the body fragments —
AckSequence, RequestRetransmit (with count + array), RejectRetransmit,
ServerSwitch, LoginRequest (tail slurp), WorldLoginRequest,
ConnectResponse, CICMDCommand, TimeSync (double), EchoRequest (float),
Flow (FlowBytes + FlowInterval). Records the raw consumed bytes into
RawBytes so CalculateHash32 can hash them verbatim — AC's CRC requires
hashing the optional section separately from the main header and the
fragments.
- Packets/Packet.cs: a record type bundling Header, Optional, Fragments,
and the raw body bytes. Produced by the decoder, consumed by downstream
handlers in Phase 4.5.
- Packets/PacketCodec.cs: TryDecode(datagram, isaac?) that
1. Unpacks the header,
2. Bounds-checks DataSize against the buffer,
3. Parses the optional section,
4. If BlobFragments is set, walks the body tail as back-to-back
MessageFragment.TryParse calls,
5. Computes headerHash + optionalHash + fragmentHash,
6. Verifies CRC:
- Unencrypted: sum equals header.Checksum
- Encrypted: (header.Checksum - headerHash) XOR payloadHash must
equal the next ISAAC keystream word (which is consumed on match)
Returns a PacketDecodeResult(Packet?, DecodeError) so callers can log
and drop malformed packets instead of throwing.
- Public helper PacketCodec.CalculateFragmentHash32 so tests (and later
the encode path) can reuse the fragment-hash math.
Tests (7 new, 44 total in net project, 121 across both test projects):
- Minimal valid packet with AckSequence optional, no fragments, plain
checksum — verifies optional parse + CRC accept
- Wrong checksum rejected
- Buffer shorter than header → TooShort
- Header DataSize > buffer → HeaderSizeExceedsBuffer
- Packet with BlobFragments flag + one fragment: parses fragment and
validates the full headerHash + fragmentHash equals wire checksum
- Encrypted checksum ROUND TRIP: two ISAAC instances with same seed,
one encodes the checksum key, one decodes — validates the
(Header.Checksum - headerHash) XOR payloadHash == isaacNext contract
byte-for-byte
- Encrypted checksum with wrong key on the wire → rejected
Known limitation: the parser advances past WorldLoginRequest and
ConnectResponse their full 8 bytes whereas ACE "peeks" them (seek/reset).
The on-wire byte count is the same, only the read-position behavior
differs; any consumer that wanted to re-read those sections can do so
from Packet.BodyBytes.
Phase 4.5 (NetClient UDP pump + handshake state machine) next.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ports the fragment layer of the AC UDP protocol. A UDP packet's body is
zero or more message fragments back-to-back; a logical GameMessage that
doesn't fit in ~448 bytes gets split across multiple fragments sharing
the same Id with differing Index values. The assembler handles
reassembly across arbitrary arrival ordering and duplicate fragments.
Added (all reimplemented from ACE's AGPL reference, see NOTICE.md):
- Packets/MessageFragmentHeader.cs: 16-byte fragment header struct
with Pack/Unpack, constants for MaxFragmentSize (464) and
MaxFragmentDataSize (448). Bit-layout doc comment documents what
each field is for.
- Packets/MessageFragment.cs: readonly record struct bundling a
header with its payload bytes; TryParse(source) parses one fragment
from the start of a buffer and returns (fragment, consumed) for
incremental parsing of multi-fragment packets. Refuses to parse
fragments with impossible TotalSize (too small for header, too
large for the 464-byte max, or larger than the source buffer).
- Packets/FragmentAssembler.cs: buffers partial messages keyed by
fragment Id. Ingest(frag, out queue) returns the assembled byte[]
when the last fragment arrives, null while still waiting. Key
correctness properties, all tested:
* Single-fragment (Count=1) shortcut releases with no buffering
* Out-of-order arrival (e.g. 2, 0, 1) releases on last arrival
and assembles in INDEX order, not arrival order
* Duplicate-fragment idempotence (re-sending same index is a no-op)
* Missing fragments stay buffered; DropAll() forcibly clears them
* Two independent messages can be assembled in parallel without
interfering
* messageQueue captured from first-arriving fragment (it's a
property of the logical message, not individual fragments)
Tests (17 new, 37 total in net project, 114 across both test projects):
- MessageFragmentHeader (4): pack/unpack round-trip, little-endian
wire format, constants, size-check throw
- MessageFragment (6): complete parse, insufficient header, oversized
TotalSize, undersized TotalSize, incomplete body, two-back-to-back
incremental parse
- FragmentAssembler (7): single-fragment, in-order 3-fragment,
out-of-order 3-fragment (tests index-order assembly), duplicate
idempotence, missing-fragment buffered, two parallel messages,
DropAll
Phase 4.4 (GameMessage reader + opcode handlers) next.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the 20-byte AC UDP packet header struct + pack/unpack + its
checksum helper, and the Hash32 primitive the checksum uses.
Hash32 (Cryptography/Hash32.cs):
- Seeds accumulator with length << 16
- Sums input as little-endian uint32s word-aligned
- Folds any trailing 1-3 bytes via descending shift (24 → 16 → 8)
- Hand-computed golden values for 4-byte, 5-byte, and each 1/2/3
tail-byte case — no oracle needed, algorithm is simple enough to
verify by tracing
PacketHeader (Packets/PacketHeader.cs):
- Pack/Unpack: Sequence, Flags, Checksum, Id, Time, DataSize, Iteration
(20 bytes, little-endian on the wire)
- CalculateHeaderHash32: substitutes the 0xBADD70DD sentinel for the
Checksum field before hashing (matches AC retail + ACE convention —
without it the checksum would chicken-and-egg on itself). Uses a
local struct copy so the real Checksum isn't mutated on the caller.
- HasFlag for bitmask queries
PacketHeaderFlags (Packets/PacketHeaderFlags.cs):
- Full flag enum from ACE reference: Retransmission, EncryptedChecksum,
BlobFragments, ServerSwitch, ConnectRequest/Response, LoginRequest,
AckSequence, TimeSync, Disconnect, NetError, EchoRequest/Response,
Flow, and friends
Tests (15 new, 20 total in net project, 97 across both projects):
Hash32 (7):
- Empty returns 0
- 4-byte known value (hand-computed from bit layout)
- 5-byte value with one tail byte
- 1/2/3 tail-byte boundary cases (verifies 24/16/8 shift ordering)
- Determinism
PacketHeader (8):
- Pack/Unpack round-trip preserving all 7 fields
- Pack writes little-endian wire format in byte order
- HasFlag single and multi-bit
- CalculateHeaderHash32 invariance under Checksum field changes
(the critical property — verifies the BADD sentinel substitution)
- CalculateHeaderHash32 doesn't mutate
- CalculateHeaderHash32 determinism
- Unpack/Pack size-check throw
User confirmed an ACE server is running on localhost for the future
Phase 4.6 live integration step. Credentials will be read from env
vars at runtime, never committed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
First step of Phase 4 (networking). Adds a new AcDream.Core.Net project
for the AC UDP protocol implementation and a matching AcDream.Core.Net.Tests
project. Keeps networking isolated from rendering and the dat layer,
which also keeps the AGPL-reference-material hygiene cleaner.
AcDream.Core.Net/NOTICE.md documents the attribution policy: we read
ACE's AGPL network code (and holtburger's Rust ac-protocol crate) to
understand AC's wire format, but we reimplement everything in acdream's
own style. Wire-format facts aren't copyrightable; specific code is.
This commit adds one component: IsaacRandom — AC's variant of Bob
Jenkins' ISAAC PRNG, used to XOR a keystream into the CRC field of
every outbound packet for authentication. Clean-room reimplementation
based on reading:
- references/ACE/Source/ACE.Common/Cryptography/ISAAC.cs (AGPL oracle)
- Bob Jenkins' public ISAAC algorithm description
Implementation notes:
- 256 uint32 mm[] state, 256 uint32 rsl[] output buffer, a/b/c regs
- Initialize() runs 4 golden-ratio Mix() warmup rounds then two fold-in
passes over rsl[] and mm[] (fresh instance → both start as zeroes)
- AC variant: seed is exactly 4 bytes, interpreted as little-endian
uint32 assigned to a = b = c before the first Scramble()
- Scramble() produces 256 output words in one pass; Next() consumes
them backwards from offset 255 → 0, re-scrambling at offset -1
- Test seed 0x12345678 matches ACE's reference output byte-for-byte
across the first 16 values (golden vectors transcribed from a
throwaway oracle harness that compiled ACE's ISAAC.cs and printed
its output; the harness was deleted after extracting the values)
Tests (5, all passing):
- Next_Seed12345678_MatchesAceGoldenVectors: 16 golden uint32 values
- Next_TwoInstancesSameSeed_ProduceIdenticalSequence: 1000 outputs
- Next_DifferentSeeds_ProduceDifferentFirstOutput
- Next_512Calls_SpansTwoScrambleBatches: >400 distinct values in 512
outputs (catches all-zero / stuck-at-one bugs at scramble boundary)
- Ctor_ShortSeed_Throws
Both test projects still green: 77 core + 5 net = 82/82.
Phase 4.2 (packet framing + checksum) next.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>