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>
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>
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>
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>
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>
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>
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.
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>
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>
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>
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>
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)
Addresses code quality review of 9161868:
- PluginDiscoveryResult.Error is now Exception? rather than string?,
preserving stack traces across the plugin boundary for logging
- PluginDiscovery.Scan orders subdirectories by ordinal string comparison
so plugin load order is reproducible across platforms
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