Commit graph

230 commits

Author SHA1 Message Date
Erik
0aea24c78e feat(net): extract GameMessage opcodes from live fragment stream (Phase 4.6f)
Reassembles the fragments arriving from the live handshake into full
game message bodies, reads the opcode from the first 4 bytes, and
identifies them by name. On the live wire we now see exactly the
sequence ACE sends right after HandleConnectResponse:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Phase 4.4 (GameMessage reader + opcode handlers) next.

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

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

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

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

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

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

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

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

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

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

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

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

Phase 4.2 (packet framing + checksum) next.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:14:28 +02:00
Erik
e0dfecdf23 feat(core+app): per-cell terrain texture blending (Phase 3c.4)
The visual-win commit that wires up the Phase 3c.1/.2/.3 building blocks:
Holtburg's terrain now uses AC's real per-cell texture-merge blend
(base + up to 3 terrain overlays + up to 2 road overlays, with alpha
masks from the alpha atlas) instead of the flat per-vertex single-layer
atlas lookup that preceded it.

Geometry rewrite:
  - New TerrainVertex struct (40 bytes): Position(vec3) + Normal(vec3) +
    Data0..3 (4x uint32 packed blend recipe)
  - LandblockMesh.Build is now cell-based: iterates 8x8 cells instead of
    the old 9x9 vertex grid, emits 6 vertices per cell (two triangles),
    384 total vertices per landblock
  - For each cell: extract 4-corner terrain/road values → GetPalCode →
    BuildSurface (cached across landblocks via a shared surfaceCache) →
    FillCellData → split direction from CalculateSplitDirection → emit
    6 vertices in the exact gl_VertexID % 6 order WorldBuilder's vertex
    shader expects
  - Per-vertex normals preserved via Phase 3b central-difference
    precomputation on the 9x9 heightmap, interpolated smoothly across
    the cell (we deliberately didn't adopt WorldBuilder's dFdx/dFdy
    flat-shade approach — Phase 3a/3b user-tuned lighting was worth
    keeping)

Renderer rewrite:
  - TerrainRenderer VAO: vec3 Position, vec3 Normal, 4x uvec4 byte
    attributes for Data0..3. The uvec4-of-bytes read pattern matches
    Landscape.vert so the ported shader math stays byte-for-byte
    identical to WorldBuilder's.
  - Binds both atlases: terrain atlas on unit 0 (uTerrain), alpha atlas
    on unit 1 (uAlpha)

Shader rewrite (ports of WorldBuilder Landscape.vert/.frag, trimmed):
  - terrain.vert: unpacks the 4 data bytes + rotation bits, derives the
    cell corner from gl_VertexID % 6 + splitDir, rotates the cell-local
    UV per overlay's rotation field, and computes world-space normal
    for the fragment shader
  - terrain.frag: maskBlend3 three-layer alpha-weighted composite for
    terrain overlays, inverted-alpha road combine, final composite
    base * (1-ovlA)*(1-rdA) + ovl * ovlA*(1-rdA) + road * rdA. Phase
    3a/3b directional lighting applied on top (SUN_DIR, AMBIENT=0.25,
    DIFFUSE=0.75, in sync with mesh.frag).
  - Editor uniforms (grid, brush, unwalkable slopes) deliberately
    omitted — not applicable to a game client
  - Per-texture tiling factor hardcoded to 1.0 for now (WorldBuilder
    reads it from uTexTiling[36] uploaded from the dats); one tile per
    cell = 8 tiles per landblock-side, slightly coarser than the old
    ~2x-per-cell tiling. Tunable via the TILE constant if needed.

TerrainAtlas grew parallel TCode/RCode lists (CornerAlphaTCodes,
SideAlphaTCodes, RoadAlphaRCodes) so TerrainBlendingContext can be
built without the mesh loader touching the dats directly.

GameWindow builds a TerrainBlendingContext once, shares a Dictionary
<uint, SurfaceInfo> surfaceCache across all 9 landblocks. Output:
"terrain: 137 unique palette codes across 9 landblocks" — avg ~15
unique per landblock, cache reuse healthy.

LandblockMeshTests rewritten for 384-vertex layout. 77/77 tests green.
Visual smoke run launches clean: no shader compile/link errors, no
GL warnings, terrain renders to the screen.

User visual verification is the final acceptance gate for Phase 3c.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:02:15 +02:00
Erik
a6cd56663f feat(core): terrain surface recipe + cell data packing (Phase 3c.3)
Ports WorldBuilder's full BuildTexture / FillCellData pipeline as
pure CPU functions in TerrainBlending.cs, along with the SurfaceInfo
recipe record and a TerrainBlendingContext input struct that carries
the atlas index lists the algorithm needs.

This is still pure algorithm work — no GL, no shaders, no mesh gen
changes. Visual Phase 3c.4 next commit wires it into LandblockMesh
and rewrites the terrain shaders to consume Data0..3.

Added (all ports of WorldBuilder LandSurfaceManager methods):
  - ExtractTerrainCodes: inverse of GetPalCode terrain bits
  - PseudoRandomIndex: deterministic hash over palette code for alpha
    variant selection; overflow-dependent int math matches WorldBuilder
    byte-for-byte
  - RotateTerrainCode: *2 with wrap (1→2→4→8→1, multi-corner patterns
    handled in tests)
  - GetRoadCodes: decodes the 8-bit road mask into up to two canonical
    road patterns + allRoad flag; magic 0xE/0xD/0xB/0x7 switch kept verbatim
  - FindTerrainAlpha: picks corner vs side alpha map, walks the 4
    rotations looking for a TCode match, returns (alphaLayer, rotation)
    or (255, 0) for "not found"
  - FindRoadAlpha: same idea for road maps, iterates all maps from a
    pseudo-random offset
  - BuildSurface: composes the above into a SurfaceInfo, handling the
    all-road, all-duplicate-terrain, and distinct-terrain cases via
    BuildOverlayLayers + BuildWithDuplicates (ports GetTerrainTextures +
    BuildTerrainCodesWithDuplicates)
  - FillCellData: packs a SurfaceInfo + CellSplitDirection into the 4
    uint32 vertex attributes Data0..Data3. Byte layout documented in
    XML comment and matches WorldBuilder's Landscape.vert uvec4 byte
    unpacking exactly.

SurfaceInfo record carries resolved atlas byte layers directly (base +
3 terrain overlays + 2 road overlays, each with optional alpha layer
and 0-3 rotation). Sentinel 255 = "slot unused".

Tests (14 new, 75/75 total):
  - ExtractTerrainCodes round-trip with GetPalCode
  - RotateTerrainCode single-corner cycle + multi-corner patterns
  - GetRoadCodes: no-road, all-road, single-corner road
  - PseudoRandomIndex: range, count=0 guard, determinism
  - BuildSurface: all-grass → base only; all-road → road as base;
    two-grass-two-dirt → base + overlay
  - FillCellData: full round-trip bit layout with recognizable
    byte values in every slot, plus a no-road1 case that verifies
    the texRd1 slot collapses to 255 when road1 alpha is absent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:53:32 +02:00
Erik
a8459eecbb feat(core+app): alpha atlas loading (Phase 3c.2)
Loads AC's terrain blending alpha masks into a second GL_TEXTURE_2D_ARRAY
alongside the existing terrain atlas. The alpha atlas is built but not
yet sampled by any shader — that wiring lands in Phase 3c.4.

SurfaceDecoder additions:
  - Handles PFID_A8 (generic single-byte-alpha) by replicating each
    alpha byte into all four RGBA channels
  - Same branch handles PFID_CUSTOM_LSCAPE_ALPHA (0xF4), AC's landscape-
    specific alpha format — the bit layout is identical, just a different
    format ID to distinguish the asset class in the dats. I only found
    this by adding a diagnostic in the first iteration (initial attempt
    returned Magenta for every alpha map because I only wired PFID_A8)
  - 3 new tests: 2x2 A8 round-trip, short-source fallback, and a
    CUSTOM_LSCAPE_ALPHA test verifying it's routed through the same path

TerrainAtlas additions:
  - New GlAlphaTexture property plus CornerAlphaLayers / SideAlphaLayers
    / RoadAlphaLayers index lists so the coming BuildSurface port can
    cite atlas layers by source category
  - BuildAlphaAtlas walks TexMerge.CornerTerrainMaps, SideTerrainMaps,
    RoadMaps and uploads each decoded mask as a layer in insertion
    order; categories carry their atlas-layer index in the respective
    list
  - Fallback handling (single-layer white) when TexMerge is missing or
    every map fails to decode
  - Alpha atlas uses ClampToEdge wrap so repeating tile sampling at
    mask boundaries doesn't produce seams
  - Dispose() now cleans up both textures

On Holtburg's region the log prints:
  TerrainAtlas: 33 terrain layers at 512x512
  AlphaAtlas:    8 layers at 512x512  (corners=4, sides=1, roads=3)

Tests: 61/61 passing. No visual change expected this commit (shader
still ignores Data0..3 and the alpha sampler).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:45:40 +02:00
Erik
e6cfcb612b feat(core): terrain palette + cell split math (Phase 3c.1)
First of four steps porting WorldBuilder's texture-merge terrain
blending. This commit is pure CPU math with no GL or dat dependencies
so the ported logic can be verified in isolation before it starts
driving real rendering.

Ported:
  - GetPalCode(r1..r4, t1..t4): packs corner terrain/road bits into
    a 32-bit palette code (bit layout documented in XML comment)
  - CalculateSplitDirection: deterministic hash picking SWtoNE vs
    SEtoNW triangulation for a cell; magic constants kept exact to
    match AC's server-side collision triangulation
  - CellSplitDirection enum with values matching WorldBuilder's so
    later bit-packing stays byte-identical

Tests (10 new, 58/58 passing total):
  - GetPalCode golden value for all-grass-no-roads: 0x10008421
    (hand-computed from the bit layout, not derived from a run)
  - GetPalCode all-zero produces only the sizeBits marker
  - GetPalCode determinism, road-flag isolation (r1 flip touches
    only bit 26), size bit always set, terrain region bounded to
    bits 0-19
  - CalculateSplitDirection hand-computed golden for (0,0,0,0):
    (1813693831 - 1369149221) * (1/2^32) ~= 0.1035 < 0.5 -> SWtoNE
  - Determinism
  - Across a full 8x8 landblock the hash produces a mix of both
    split directions (would fail if the hash collapses)

Deferred to Phase 3c.3 (need dat data for TexMerge):
  BuildSurface, FillCellData, PseudoRandomIndex, SurfaceInfo

Reference: WorldBuilder Chorizite.OpenGLSDLBackend/Lib/LandSurfaceManager.cs
           WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:36:35 +02:00
Erik
0c0c042dca feat(core): add IGameState, IEvents, WorldEvents with replay-on-subscribe
Adds WorldEntitySnapshot, IGameState, IEvents abstractions; WorldEvents
implements replay-on-subscribe with per-handler exception swallowing;
WorldGameState tracks entities; AppPluginHost exposes all three; stubs
wired in Program.cs to keep build green ahead of Task 9 live wiring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:29:29 +02:00
Erik
78ce099440 fix(core): LandblockMesh keys atlas lookup on TerrainInfo.Type
Task 1's subagent used the raw ushort as the map key because the test
used raw ushort 7 as the value. But the atlas map is built from
Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc which keys on
TerrainTextureType enum values, extracted from bits 2-6 of the
TerrainInfo ushort per DatReaderWriter's Types/TerrainInfo.cs.

Reverts to using block.Terrain[hi].Type so the Task 2 TerrainAtlas can
actually find matching keys against real dat terrain. The test is
updated to encode Type=7 correctly as (7 << 2) in the raw ushort.
2026-04-10 20:18:09 +02:00
Erik
324abed6eb feat(core): add Vertex.TerrainLayer + LandblockMesh layer map
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:16:25 +02:00
Erik
cc55c3f812 fix: heightmap transpose + solid-color + translucency + clipmap textures
Three root causes found via systematic debugging after the user reported
that the dc60405 texture fix and 4763b97 height table fix had no visible
effect on Holtburg.

## Heightmap transpose (LandblockMesh.Build)

Phase 1's LandblockMesh.Build indexed block.Height as y*9+x but AC packs
per-vertex heights in x-major order (x*9+y, matching ACViewer's
LandblockStruct: Height[x * VertexDim + y]). The bug was invisible on
flat landblocks (Phase 1 smoke test) but left buildings buried by 10-13
world-Z units on Holtburg, because building Frame.Origin positions
reference the un-transposed ground truth.

Diagnostic evidence (before fix, Holtburg 0xA9B4FFFF):
  entity 0x020000A5 at ( 84.6,126.0) entityZ= 66.03 terrainZ= 78.15 delta=-12.13
  entity 0x02000118 at ( 74.2,139.9) entityZ= 66.03 terrainZ= 78.92 delta=-12.89

After fix: deltas are 0.03 to 2.18 — buildings now sit on the ground
with small positive offsets for foundations.

Regression test added: Build_HeightmapPackedAsXMajor_NotYMajor asserts
asymmetric heights land at the correct world positions.

## Solid-color surfaces with Translucency=1.0 (SurfaceDecoder.DecodeSolidColor)

The "bright pink doors and windows" the user saw were 11 Holtburg
surfaces with OrigTextureId==0 — these carry a ColorValue instead of
a texture chain. Phase 2a's TextureCache dropped them into the magenta
fallback. All 11 turned out to be Base1Solid|Translucent with
Translucency=1.00, meaning "fully transparent placeholder surface"
(debug ColorValue is gray/green/red/blue/black, never displayed).

DecodeSolidColor now takes a translucency parameter and multiplies
alpha by (1 - translucency), so Translucency=1.0 → alpha=0, and the
mesh shader's existing alpha discard (< 0.5) makes the pixel invisible.

TextureCache honors Surface.Type.HasFlag(Base1Solid) and passes
surface.Translucency through.

Regression tests added: DecodeSolidColor_Opaque_PreservesAlpha and
DecodeSolidColor_FullyTranslucent_AlphaGoesToZero.

## Clipmap alpha-key (DecodeIndex16)

AC convention (per ACViewer TextureCache.IndexToColor): on surfaces
marked Base1ClipMap, palette indices 0..7 are treated as fully
transparent regardless of their actual palette color. Without this,
low-index pixels on clipmap surfaces (typically doorway cutouts and
foliage) render as opaque using whatever sentinel color is at those
palette slots.

DecodeRenderSurface now takes an isClipMap parameter. TextureCache
passes Surface.Type.HasFlag(Base1ClipMap). DecodeIndex16 forces
rgba=(0,0,0,0) when isClipMap && idx < 8.

Regression test added: DecodeIndex16_ClipMap_ZerosAlphaForLowIndices.

## Notes

- dc60405's PFID_INDEX16 palette decoder remains correct — no change.
- 4763b97's LandHeightTable wiring remains correct — real-table lookup
  still runs, it just happens to be linear at Holtburg's height range.
  The fix is forward-compatible with mountains elsewhere.
- All three bugs were invisible to the original unit tests. The new
  regression tests pin them down.

## State

- dotnet build: 0 warnings, 0 errors
- dotnet test: 42 passing (was 38 + 4 new)
- Runtime: 126 entities hydrated on Holtburg, no exceptions, no
  magenta fallback (counter was 11, now 0 via diagnostic confirmation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:37:06 +02:00
Erik
4763b973da fix(terrain): use real LandHeightTable from Region dat
Phase 1 simplified per-vertex height as byte * 2.0f, but AC stores
heights as byte indices into a 256-entry non-linear float lookup
(Region.LandDefs.LandHeightTable). Static object placements in
LandBlockInfo use the real table, so terrain rendered with the
simplified scale left buildings floating or buried.

LandblockMesh.Build now takes an explicit float[] heightTable so
the core code stays testable without a DatCollection. GameWindow
loads Region id 0x13000000 once at startup and passes its
LandDefs.LandHeightTable into every landblock mesh build. The
Phase 1 tests use an identity table (i * 2f for i in 0..255) so
their expectations remain unchanged.

Addresses the 'buildings buried and floating' issue the user
observed after the Phase 2a visual checkpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:09:27 +02:00
Erik
5d35f4fe46 feat(core): add WorldView with 3x3 neighbor landblock computation 2026-04-10 18:02:41 +02:00
Erik
8f5b498be6 feat(core): add SetupMesh.Flatten for single-level part hierarchy 2026-04-10 18:01:16 +02:00
Erik
473a06c534 feat(core): add LandblockLoader with Stab+Building → WorldEntity mapping 2026-04-10 17:58:30 +02:00
Erik
dbf913ebb4 feat(core): add SurfaceDecoder for A8R8G8B8 and BCn formats 2026-04-10 17:56:15 +02:00
Erik
f915a13263 feat(core): add GfxObjMesh.Build multi-surface mesh extractor 2026-04-10 17:52:09 +02:00
Erik
baf0db303d feat(core): add LandblockMesh flat-terrain generator
Pure CPU mesh generator: takes a DatReaderWriter LandBlock DBObj and
produces 81 vertices + 128 triangles covering 192x192 world units.
Vertices are a readonly record struct (position, normal, texcoord)
so the upcoming GPU upload in Task 8 can sizeof() them directly.
Height byte -> world z uses a simple 2x scale; the real AC height
lookup table is a Phase 2+ concern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:37:52 +02:00
Erik
f6a57cbc6c refactor(core): harden PluginLoader per code review
Addresses code quality review of a7f0732:
- LoadedPlugin now holds the AssemblyLoadContext explicitly so Task 10
  can call Unload() for hot reload (Critical)
- LoadedPlugin.Error is Exception? to match PluginDiscoveryResult and
  preserve stack traces; synthetic failures build FileNotFoundException
  and InvalidOperationException (Important)
- PluginLoader falls back to ReflectionTypeLoadException.Types if
  GetTypes() can't fully resolve (Important)
- Hardcoded abstractions assembly name is now a const (Minor)
2026-04-10 09:57:45 +02:00
Erik
a7f0732026 feat(core): add PluginLoader with collectible ALC 2026-04-10 09:51:16 +02:00
Erik
91618682e2 feat(core): add PluginDiscovery directory scan 2026-04-10 09:35:58 +02:00
Erik
99d2702c13 refactor(core): harden PluginManifest error model
Addresses code quality review of c082ecf:
- Require takes a literal JSON field name, no more fragile PascalCase->camelCase transform
- Parse_MissingRequiredField_Throws asserts exact message, not substring
- Remove unused using System.Text.Json.Serialization
2026-04-10 09:33:00 +02:00
Erik
c082ecf36a feat(core): add PluginManifest json parsing 2026-04-10 09:28:08 +02:00
Erik
caf57cca3e chore: phase 1 — add Core, Abstractions, App, Tests projects 2026-04-10 09:22:33 +02:00