Commit graph

64 commits

Author SHA1 Message Date
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
8b940bd038 docs(claude): project goal + lead-developer operating instructions
CLAUDE.md captures the project goal (modern C# AC client with
first-class plugin support) and sets Claude's operating mode to
"lead developer" — drive phases continuously and only pause for
decisions that genuinely need the user's input. Reduces check-in
overhead on the long tail of phase work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:39:21 +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
3fb6b67735 docs(plan): Phase 3c terrain blending plan
Blending-focused plan for Phase 3c: port WorldBuilder's per-cell
palette-code + alpha-atlas terrain texture merge scheme so terrain
type boundaries blend smoothly instead of stair-stepping.

Scope deliberately excludes chunking (deferred to a possible Phase 3d
when streaming actually matters) so the work stays focused on the
visible win.

Execution split into 4 small commits:
  3c.1 palette math (pure CPU, unit tested against golden values)
  3c.2 alpha atlas loading
  3c.3 per-cell vertex layout refactor
  3c.4 shader rewrite (the visual-win commit)

Each step is runnable on its own so if 3c.4 looks wrong we know the
earlier steps were fine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:43:04 +02:00
Erik
89dc791510 tune(shaders): more dramatic Phase 3a lighting contrast
Original Phase 3a constants had SUN_DIR=(0.4,0.3,0.8) — heavily
vertical, so roofs and ground both landed near peak brightness and
only walls dropped. Combined with AMBIENT=0.4/DIFFUSE=0.6 the lit-vs-
shadow contrast was ~2.2x, which was real but hard to read through
textures. User feedback: "Ligtning looks the same I think."

Diagnosed with a temporary grayscale-lighting fragment output — walls
on different sides of the same building did show different brightness,
confirming the Phase 3a/3b pipeline is wired correctly end-to-end and
the issue was purely perceptual contrast.

Retuned: SUN_DIR=(0.5,0.4,0.6) (more oblique), AMBIENT=0.25, DIFFUSE=0.75.
Contrast ratio now ~3.3x. User-verified visually.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:25:35 +02:00
Erik
3c3f5267de docs(plan): Phase 4 networking design
Design-only doc covering the networking subsystem needed to bring
the static Holtburg scene online: UDP packet codec, ISAAC keystream,
fragment reassembly, GameMessage dispatch, WorldSession lifecycle,
and composite IGameState so server CreateObject messages flow through
the same IEvents pipeline the dat-hydrated static entities already use.

Also documents that ACE is the authority for the protocol (neither
WorldBuilder nor ACViewer implements client networking) and captures
the license hygiene plan: read ACE's AGPL source for protocol knowledge,
reimplement from scratch, credit in NOTICE.md.

Explicit win condition for Phase 4: the foundry statue finally appears,
because it's a server weenie, not a dat decoration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:31:26 +02:00
Erik
c95481ea69 feat(core): per-vertex terrain normals via central differences (Phase 3b)
LandblockMesh was hardcoding Normal=Vector3.UnitZ for every vertex,
which meant Phase 3a's directional lighting gave every terrain fragment
the same brightness — flat-looking terrain regardless of slope.

Now computes a real per-vertex normal by sampling the 4 heightmap
neighbors and taking central differences on the heights. The surface
is z=h(x,y), tangents are Sx=(1,0,dh/dx) and Sy=(0,1,dh/dy), and the
normal is their cross product: (-dh/dx, -dh/dy, 1) normalized. Edge
vertices use forward/backward difference instead of central.

Heightmap is pre-sampled into a 9x9 float grid before the vertex loop
so neighbor lookups don't hit the heightTable dictionary-style 81 times
per vertex — one pass to precompute, one pass to emit vertices.

Existing tests still pass: flat landblocks produce flat normals
(constant heights → zero derivatives → UnitZ), so the Phase 1 tests'
"all vertices same Z" assertion remains accurate.

Combined with 3268556 (lighting), terrain hills now visually catch the
sun on their sunward slopes and darken on shadowed slopes. Holtburg's
gentle rolling hills should look considerably more three-dimensional.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:23:35 +02:00
Erik
3268556bd0 feat(app): directional lighting on terrain and static meshes (Phase 3a)
Adds a hardcoded sun direction + ambient + Lambert diffuse to both
terrain.frag and mesh.frag. Both vertex shaders now forward a world-
space normal (computed as mat3(uModel) * aNormal) for the fragment
shader to dot against the sun vector.

Lighting model:
  final_rgb = texture_rgb * (AMBIENT + DIFFUSE * max(0, dot(N, SUN)))
where AMBIENT=0.4, DIFFUSE=0.6, SUN=normalize(0.4,0.3,0.8).

Building walls facing the sun light up, walls in shadow dim to ~40%.
Scenery (trees, bushes, rocks) with real per-vertex normals from SWVertex
shades naturally. Terrain currently uses flat UnitZ normals so every
terrain fragment gets the same contribution — terrain will look a bit
washed out compared to real AC until a Phase 3b pass computes per-vertex
landblock normals from the heightmap.

Non-uniform scale (from scenery's random scale baked into MeshRef
PartTransform) would technically require the inverse-transpose for
correct normals, but scenery uses uniform scale so mat3(uModel) is
good enough. Flagging as a known Phase 3+ concern if nonuniform scale
ever shows up.

Build clean, runtime clean: 1133 entities hydrated, no shader compile
errors, process runs through startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:22:10 +02:00
Erik
0f7cd9caaf revert(app): drop foundry-statue camera target — it's a weenie
The Holtburg foundry statue is a selectable in-game item (weenie
template spawned by the server), not a static client-dat entity. It's
correctly absent from:
- LandBlockInfo.Objects (Stabs)
- LandBlockInfo.Buildings (BuildingInfo)
- EnvCell.StaticObjects
- Region.SceneInfo procedural scenery (SceneryGenerator explicitly
  skips ObjectDesc entries where WeenieObj != 0 — that branch is the
  weenie-reference flag)

The client is behaving correctly. The statue will appear once we
connect to a live ACEmulator instance and receive CreateObject network
messages placing the weenie at its spawn position — Phase 4+ work.
Setup 0x02000081 that I speculatively identified as "the statue" is a
different decoration entirely; there's no need to pin the default
camera at its location.

Revert the camera targeting from eb27e3c. OrbitCamera defaults back to
(96, 96, 0) centered on Holtburg, FlyCamera to (96, 96, 150).
2026-04-10 22:14:35 +02:00
Erik
eb27e3c2be chore(app): point default camera at foundry statue location
Diagnostic instrumentation confirmed Setup 0x02000081 at (107.5, 36.0,
100.85) from EnvCell 0xA9B40166 is the most plausible candidate for the
"greenish statue on top of the foundry" the user described. That
position is directly above Building[7] (the foundry, ModelId=0x01000C17)
which sits at (107.5, 36.0, Z=94.0). The statue's Setup has 1 part
(GfxObj 0x01000622), 80 triangles, 127 vertices, 4 valid surfaces (3
Base1Image + 1 Base1ClipMap), all Translucency=0.00 and with resolvable
OrigTextureIds. Every signal says it SHOULD be rendering.

To verify visually, this commit points the default OrbitCamera target
and FlyCamera initial position at that exact (107.5, 36.0, ~101)
location so the user sees the foundry rooftop immediately on launch
without having to hunt. If the statue is visible at the camera target
on run, it's present and we're done. If the camera target is empty,
we've precisely localized the rendering bug to "Setup 0x02000081 mesh
build succeeds but GL draw produces nothing visible" which would point
at polygon winding order or NegSurface-only polygons in GfxObjMesh.Build.

Removes all the transient DIAG instrumentation from the session.
2026-04-10 22:10:01 +02:00
Erik
82e857cf21 fix(app): interior stabs are landblock-local, not cell-local
Phase 2d's initial composition was cellOrigin + cellRot*stabLocal,
assuming EnvCell.StaticObjects carried cell-local frames and that
EnvCell.Position was the cell-to-landblock transform. User reported
all interior objects "far up in the air" after the initial Phase 2d.

Diagnostic confirmed the real shape: EnvCell 0xA9B40100.Position.Origin
= (84.1, 131.5, 66.0) and the first Stab inside it had Frame.Origin =
(92.1, 131.5, 68.0). Both are in landblock-local X/Y/Z space — the
stab is 8 units east and 2 units up from the cell's registered origin
but expressed in the SAME coordinate space, not as an offset. Adding
the cell origin on top double-counted ~155 units in Z and put the
statue at worldPos Y=263 Z=233+, completely out of range.

EnvCell.Position appears to tell the physics engine which landblock
region owns the cell (for collision/portal lookups) rather than acting
as a cell-to-landblock transform for contained objects. The stabs are
already in the same coordinate system as LandBlockInfo.Objects stabs.

Fix: drop the cell origin + cell rotation from the composition. World
position is now just stab.Frame.Origin + lbOffset, mirroring the
regular Stab handling exactly.

Smoke verified: 475 interior objects still hydrate, process runs clean.
Visual verification pending.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:51:34 +02:00
Erik
abcfb55418 feat(app): walk interior EnvCells for in-building static objects
Addresses the "foundry statue still missing" user feedback after Phase
2c. Diagnostic spike confirmed the statue is not a Stab on
LandBlockInfo.Objects, not a Building on LandBlockInfo.Buildings, and not
a hierarchical Setup part — the highest entity on Holtburg center was
at Z=104 with no entity above the foundry cluster.

Root cause per LandBlockInfo.NumCells's own doc comment: interior cells
live at dat id 0xAAAA0100 + N where AAAA is the landblock id high word
and N runs from 0 to NumCells-1. Each EnvCell has a StaticObjects list
(List<Stab>) holding in-building decorations — statues, furniture,
lamps, crates, rugs, and the like. We weren't loading any of it.

GameWindow.OnLoad now iterates each landblock's LandBlockInfo.NumCells,
loads each EnvCell at the canonical id, walks its StaticObjects, and
hydrates each as a WorldEntity. Position is composed as:

    worldPos = landblockOffset + cellOrigin + (cellRotation * stabLocal)
    worldRot = cellRotation * stabRotation

where cellOrigin/cellRotation come from EnvCell.Position (a Frame in
landblock-local space) and stabLocal/stabRotation come from the Stab's
own Frame (cell-local space). Cell rotation is applied to the stab
position because some cells in AC are rotated relative to the landblock
grid.

Entity counts on Holtburg 3x3:
  Stabs + Buildings      239
  Procedural scenery     419
  Interior StaticObjects 475  (NEW)
  -------
  Total                 1133

Phase 2d done. Interior geometry (walls, floors, ceilings) is still not
rendered — cell shells are Phase 3+ work. Only the StaticObjects list is
walked, which is enough to surface the visible decorations the user sees
inside buildings in real AC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:45:02 +02:00
Erik
985cdc0044 Merge phase-2c/scenery: procedural scenery from Region.SceneInfo
Phase 2c: adds SceneryGenerator that places trees, bushes, rocks, and
fences across the 3x3 landblock grid using AC's deterministic LCG hash
of global cell coordinates to drive ObjectDesc frequency rolls and
pseudo-random placement/scale/rotation.

Entity count: 239 → 658 (+419 scenery). Build clean, 48 tests green,
runtime clean.

Addresses the "missing trees" half of the user's post-Phase-2b visual
feedback. The "foundry statue" half (hierarchical Setup parts) is
deferred as a potential Phase 2d.
2026-04-10 21:07:21 +02:00
Erik
9970811dc3 feat(core): procedural scenery from Region.SceneInfo (Phase 2c)
Adds SceneryGenerator.Generate which walks Region.TerrainInfo.TerrainTypes
+ Region.SceneInfo.SceneTypes for each landblock vertex, selects a scene
using the AC client's pseudo-random LCG hash of global cell coordinates,
then rolls each ObjectDesc's frequency, computes a displaced cell-local
position, random scale, and random rotation — the exact algorithm
ACViewer ports from the retail AC client's get_land_scenes().

Phase 2 rendered 239 explicit Stab+Building entities on the 3x3 Holtburg
grid but was missing every procedurally-placed tree, bush, rock, fence,
and small decoration because these are not stored as LandBlockInfo entries.
This adds 419 scenery entities across the same 9 landblocks, bringing the
total to 658.

Integration in GameWindow.OnLoad: after the existing Stab/Building
hydration loop, iterate each landblock's scenery spawns, resolve each
to a GfxObj or Setup via the same mesh pipeline, bake the random scale
into each MeshRef's PartTransform so the static mesh renderer doesn't
need a scale field on WorldEntity, and sample the landblock heightmap
bilinearly for the ground Z (simpler than ACViewer's find_terrain_poly
slope-aware placement).

Deliberate deferrals for first pass:
- No slope-based rejection (obj.MinSlope/MaxSlope). Trees may end up on
  cliffs they shouldn't be on.
- No road-overlap rejection. Scenery may spawn in roads.
- No building-overlap rejection. Scenery may clip buildings.
- No WeenieObj handling (those are dynamic spawns, not static scenery).

All three filters will be added in a follow-up phase when we have the
walkable-polygon infrastructure they need.

Build clean, 48 tests still pass, smoke verified: "scenery: spawned 419
entities across 9 landblocks", process runs without exceptions.

Addresses the user visual feedback after Phase 2b: "some extra details
are missing, like a tree and the statue on top of the foundry". The tree
issue is now fixed (419 trees/bushes/rocks/etc placed). The foundry
statue may still be missing if it's a hierarchical Setup part (Phase 2a's
SetupMesh.Flatten intentionally doesn't walk ParentIndex) — that's a
separate fix if smoke verification shows it's still missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:07:12 +02:00
Erik
fe0bfb075b Merge phase-2b/atlas-neighbors-cameras-events: Phase 2b
Phase 2b MVP complete. 9 commits implementing:
- Vertex struct gains TerrainLayer (uint, location 3 VertexAttribIPointer)
  and LandblockMesh.Build takes a TerrainTextureType → atlas layer map
- TerrainAtlas builds GL_TEXTURE_2D_ARRAY from Region.TerrainInfo.
  LandSurfaces.TexMerge.TerrainDesc, one layer per referenced TerrainTextureType
- Terrain shader rewritten to sample sampler2DArray with flat uint layer
  and per-landblock uModel translation
- TerrainRenderer grows AddLandblock(mesh, worldOrigin) for multi-landblock
  drawing, drops the ctor mesh param in favor of the new add pattern
- GameWindow replaces single-landblock load with WorldView.Load 3x3 grid;
  entity positions are translated by their source landblock's world offset
- ICamera interface extracted, OrbitCamera refactored to implement
- FlyCamera with WASD + raw cursor mouse look, pitch clamp, horizontal-plane
  movement independent of pitch
- CameraController with F toggle, Escape contextual (fly→orbit releases
  cursor; orbit→Escape closes window), cursor capture via CursorMode.Raw
- IGameState + IEvents + WorldEntitySnapshot added to Plugin.Abstractions
- WorldEvents implements replay-on-subscribe so plugins that subscribe
  after the world is loaded see every already-spawned entity exactly once
  before returning from +=
- WorldGameState exposes the entity snapshot list; AppPluginHost
  constructor gains state + events parameters
- Program.cs and GameWindow thread worldGameState + worldEvents through
  the startup; OnLoad calls FireEntitySpawned for each hydrated entity
- SmokePlugin subscribes in Enable, logs replay count at subscribe time
  (0 because it subscribes before world load) and the total seen count
  at Disable time (via live events during hydration)

6 new xUnit tests (43 → 48): 1 terrain layer mapping regression +
5 WorldEvents replay/live/unsubscribe/exception-safety tests.

Smoke verified against real dats: 33 terrain atlas layers at 512x512,
9 landblocks loaded in 3x3 grid, 239 entities hydrated across all
landblocks, build clean, no exceptions, all plugin lifecycle logs
visible including the new `sees 0 entities (replay count at subscribe)`
line.

Phase 2 (both 2a and 2b) is done. Next phase is visual verification
by the user and then Phase 3 planning (animated entities, doors,
terrain texture blending at cell boundaries, proper lighting, etc).
2026-04-10 20:32:46 +02:00
Erik
08097b6c7e feat(app): wire IGameState+IEvents into Program and SmokePlugin
Pass WorldGameState and WorldEvents into GameWindow so OnLoad fires
FireEntitySpawned and Add for each hydrated entity. SmokePlugin now
subscribes to EntitySpawned in Enable(), unsubscribes in Disable(),
and logs the replay count at subscribe time and total seen at disable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:31:50 +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
22f684e8c6 feat(app): add CameraController with F toggle and cursor capture
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:27:11 +02:00
Erik
7cf6ea267a feat(app): add FlyCamera with WASD + mouse look
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:25:19 +02:00
Erik
5640c153f3 feat(app): extract ICamera interface from OrbitCamera
Introduce ICamera (View, Projection, Aspect) and make OrbitCamera implement
it. TerrainRenderer.Draw and StaticMeshRenderer.Draw now accept ICamera,
widening the call-site contract while leaving all runtime behavior unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:24:29 +02:00
Erik
560100e5b6 feat(app): render 3x3 neighbor landblocks with texture atlas
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:23:21 +02:00
Erik
347a7e92ff feat(app): add TerrainAtlas for GL_TEXTURE_2D_ARRAY terrain textures 2026-04-10 20:19:36 +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
bc69f0cdf1 docs: phase 2b implementation plan (9 tasks) 2026-04-10 20:13:03 +02:00
Erik
f61f356145 docs: phase 2b design — atlas textures, neighbors, dual cameras, plugin api
Locks the four decisions from brainstorming: full scope (all 8 sketched
tasks in one Phase 2b), GL_TEXTURE_2D_ARRAY for terrain atlas with a
per-vertex flat uint layer attribute, raw cursor capture FlyCamera with
F toggle and Escape release, and WorldEvents.EntitySpawned with
replay-on-subscribe so plugin ordering doesn't matter.

Grows AcDream.Plugin.Abstractions by IGameState + IEvents +
WorldEntitySnapshot. Host-side WorldGameState + WorldEvents implementations
live in AcDream.App.Plugins. AppPluginHost constructor gains two
parameters. Program.cs wiring order keeps Phase 2a's Enable-before-Run,
relying on replay-on-subscribe to make subscription-before-world-load
produce the right observable behavior.

Task 1 expands Vertex struct and LandblockMesh signature — this affects
StaticMeshRenderer's vertex stride too, so Phase 2a's shader bindings
need a matching update.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:00:23 +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
dc60405ebc fix(textures): palette-indexed surfaces + alpha cutout shader
Addresses the 'doors, windows, and alpha-keyed parts render bright
pink' issue the user observed after the Phase 2a visual checkpoint.

SurfaceDecoder gains a second overload taking an optional Palette
parameter. When the render surface format is PFID_INDEX16 and a
palette is supplied, each 16-bit value in SourceData is treated as
an index into Palette.Colors (a List<ColorARGB>) and the corresponding
ARGB color's channels are written to the output buffer. The original
no-palette overload is preserved so the Task 3 unit tests that
confirm INDEX16 -> magenta fallback still describe their behavior
correctly (INDEX16 without a palette still returns magenta).

TextureCache now resolves the RenderSurface's DefaultPaletteId via
the dats and passes the resulting Palette (or null) to the decoder.

mesh.frag adds an alpha cutout: fragments with sampled alpha < 0.5
are discarded. Without this, transparent regions of alpha-keyed
textures (doors, windows, foliage cutouts) would render as opaque
rectangles using the texture's background color. This is the
standard alpha-tested approach, simpler than full alpha blending
and matches how AC's original client rendered these surfaces.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:12:05 +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
1d1e668a2f Merge phase-2/static-meshes-and-textures: Phase 2a static meshes
Phase 2a MVP complete. 8 commits implementing Tasks 1-8 of the
Phase 2 plan:
- BCnEncoder.Net 2.2.1 added to AcDream.Core
- GfxObjMesh.Build: multi-surface CPU mesh extractor from GfxObj
  vertex+polygon data, with position/UV dedupe and fan triangulation
- SurfaceDecoder: BCnEncoder.Net-backed DXT decoder + A8R8G8B8 raw,
  with 1x1 magenta fallback
- LandblockLoader: parses Stabs and BuildingInfo from LandBlockInfo
  into WorldEntity records, type-filtering to GfxObj + Setup only
- SetupMesh.Flatten: single-level part hierarchy walker
- WorldView: 3x3 neighbor landblock id computation with edge clamping
- TextureCache: App-side GL texture handle cache, walks the
  Surface → SurfaceTexture → RenderSurface chain from the dats
- StaticMeshRenderer + mesh.vert/mesh.frag + GameWindow wire-up

21 new xUnit tests (17 → 38), all green, build clean.

Smoke verified against real dats: Holtburg landblock hydrated
126 static entities (Stabs + Buildings), process runs without
exception, terrain + static meshes pipeline complete end-to-end.

Phase 2a stops here. Tasks 9-18 (conditional texture debugging,
terrain atlas, 3x3 neighbor rendering, dual cameras, plugin API
growth) deferred to Phase 2b in a future session.
2026-04-10 18:33:39 +02:00
Erik
1375780e14 feat(app): render static meshes from Holtburg LandBlockInfo 2026-04-10 18:32:09 +02:00
Erik
cefc689ba8 feat(app): add TextureCache for Surface→GL texture handle caching 2026-04-10 18:03:58 +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
01745d30ab chore(core): scaffold World/Meshing/Textures + add BCnEncoder.Net 2026-04-10 17:49:14 +02:00
Erik
af68c56b91 docs: phase 2 implementation plan (tasks 1-10 full, 11-18 sketch)
Tasks 1-10 are fully specified TDD-bite-sized and cover Phase 2a:
scaffold + GfxObjMesh.Build + SurfaceDecoder + LandblockLoader +
SetupMesh.Flatten + WorldView + TextureCache + StaticMeshRenderer +
debug + visual verification. End state: Holtburg terrain with buildings
rendered and at least some textures resolved.

Tasks 11-18 are sketches for Phase 2b in a future session: terrain
atlas, 3x3 neighbor rendering, ICamera/FlyCamera/CameraController, and
the IGameState/IEvents plugin API growth. These will be re-planned
with fresh context once Phase 2a is known to work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:27:53 +02:00
Erik
b72851a3ee docs(phase-2): refine design after DatReaderWriter spike
Verified the real GfxObj data shape against DatReaderWriter's generated
types and WorldBuilder's ObjectMeshManager. Three corrections:

- GfxObj.Surfaces is a list and Polygon.PosSurface indexes into it;
  multiple surfaces per GfxObj are the norm. GfxObjMesh.Build now
  returns IReadOnlyList<GfxObjSubMesh> — one sub-mesh per referenced
  surface. StaticMeshRenderer draws entities by grouping entities by
  GfxObj and then by sub-mesh within each GfxObj. Matches the approach
  WorldBuilder takes.

- LandBlockInfo has both Objects (Stabs — small decorations, rocks,
  trees) AND Buildings (BuildingInfo). LandblockLoader loads both
  lists and maps them uniformly to WorldEntity since both types share
  the Frame shape.

- Stab.Frame and BuildingInfo.Frame carry position+orientation only,
  no scale component. WorldEntity.Scale dropped. WorldEntitySnapshot
  loses its Scale field too. Phase 3+ can add it back if needed.

Closes the one open design question flagged in the original doc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:18:43 +02:00
Erik
7e601073d4 docs: phase 2 design — static meshes, textures, neighbors
Locks the design decisions from brainstorming: full scope (geometry +
textures + 3x3 neighbor grid), dual cameras (orbit default + FlyCamera
toggled by F), minimal plugin API growth (IGameState.Entities snapshot
+ IEvents.EntitySpawned). BCnEncoder.Net handles DXT/BCn decoding,
matching WorldBuilder's approach.

Adds three new namespaces under AcDream.Core (World, Meshing, Textures)
and three new rendering components under AcDream.App (StaticMeshRenderer,
TextureCache, FlyCamera + ICamera). Entities are deduplicated by
GfxObj id at the GPU layer; textures dedup by Surface id. Plugins
get immutable snapshots, not live references.

Rough 18-task sequence captured. Full bite-sized plan comes next via
superpowers:writing-plans.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:16:08 +02:00
Erik
2089bf3d56 Merge phase-1/terrain-and-plugins: terrain rendering + plugin scaffold
Phase 1 MVP complete. 16 commits implementing:
- Solution reorg into Plugin.Abstractions / Core / App / Tests + Smoke plugin
- Plugin system: manifest parsing, directory discovery, collectible-ALC
  loader with cross-ALC type identity preserved, failure isolation
- Terrain: LandblockMesh generator, Silk.NET window, GPU upload,
  height-ramp shader, orbit camera with mouse+scroll input
- End-to-end: Serilog + AppPluginHost + SerilogAdapter, Smoke plugin
  round-trips through Initialize/Enable/Disable alongside terrain render

17 xUnit tests green, build clean, smoke verified visually against
retail dats (Holtburg 0xA9B4FFFF renders as expected).
2026-04-10 16:59:15 +02:00
Erik
6a100ef6e7 refactor(app): harden shutdown per final review
Addresses final code review of phase-1 branch (Important I-1, I-3):

- Move plugin Enable() loop inside the same try block as GameWindow.Run,
  and wrap each Enable() in per-plugin try/catch mirroring the Disable
  loop. Previously, a plugin Enable() throwing would skip the finally
  block entirely: plugins that had already enabled would never get
  disabled, Serilog would never flush, and the exception would escape
  ungracefully. Now Enable failures are logged and contained, and
  shutdown always runs.
- Add a comment at the Get<LandBlock> call in GameWindow.OnLoad explaining
  why TryGet was avoided (the [MaybeNullWhen(false)] nullable-generic
  analysis trips TreatWarningsAsErrors).

I-2 (camera aspect doesn't update on window resize) deferred to Phase 2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:52:19 +02:00
Erik
fb83e0bb6f feat(app): wire plugin host, ship smoke plugin, log lifecycle
Phase 1 MVP end-to-end. Program.cs initializes Serilog, builds an
AppPluginHost that hands plugins a SerilogAdapter (IPluginLogger),
discovers plugins from the App's output plugins/ dir, loads each via
PluginLoader, calls Enable on all of them before opening the GameWindow,
and calls Disable in a finally block on shutdown.

AcDream.Plugins.Smoke is a new first-party plugin that logs through
the host during Initialize / Enable / Disable. Its csproj references
the abstractions with Private=false + ExcludeAssets=runtime to avoid
shipping a second copy of AcDream.Plugin.Abstractions.dll (which would
break ALC type identity). An MSBuild Target on the App project copies
the plugin DLL into plugins/AcDream.Plugins.Smoke/ and writes the
plugin.json manifest next to it.

Smoke verified against real dats. Console output observed:
  [INF] scanning plugins in ...\plugins
  [INF] smoke plugin initialized
  [INF] loaded plugin acdream.smoke (Smoke Plugin)
  [INF] smoke plugin enabled
  loaded landblock 0xA9B4FFFF
  <window renders terrain>
  [INF] smoke plugin disabled  (on shutdown)

Phase 1 done.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:46:25 +02:00
Erik
87c45c70ac feat(app): render landblock with height-ramp shader + orbit camera
Adds a 2-stage GLSL shader (vertex + fragment), a Shader helper that
compiles/links and exposes SetMatrix4 for uniforms, and an OrbitCamera
with yaw/pitch/distance and a 192-unit-centered target for a single
landblock. TerrainRenderer now takes a Shader and issues an actual
DrawElements call with uView + uProjection uniforms. GameWindow owns
the Shader and Camera, routes mouse drag to camera yaw/pitch, and
scroll wheel to camera distance.

The fragment shader maps world Z to a green-brown-white ramp so
lowlands read green, midlands brown, and peaks white — no textures
yet, but enough to visually confirm the terrain shape.

Shaders are copied to the output dir via a <None Update> item group.

Smoke verified against real dats: process stays alive with no GL
errors, no shader compile/link failures, and no exception trail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:44:08 +02:00
Erik
8356fe65a0 feat(app): load landblock from dats and upload mesh to GPU
GameWindow now owns a DatCollection + TerrainRenderer. On load it
opens the dat directory passed as argv[0] (or ACDREAM_DAT_DIR), finds
Holtburg (landblock 0xA9B4FFFF) by default with a fallback to the
first landblock in the cell b-tree, builds the CPU mesh from
LandblockMesh.Build, and uploads VBO+EBO+VAO with a 3f/3f/2f attribute
layout. No draw call yet — shader and matrix uniforms land in Task 9.

Enabled AllowUnsafeBlocks on the App csproj so the fixed-buffer upload
in TerrainRenderer compiles. Uses dats.Get<LandBlock>(id) instead of
TryGet(..., out T) to sidestep the [MaybeNullWhen(false)] analysis that
TreatWarningsAsErrors was flagging.

Smoke verified against the real retail dats: prints
"loaded landblock 0xA9B4FFFF" and the window stays alive with no GL
errors or exceptions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:42:13 +02:00