statue identified (Phase 4.7h/i/j/k/l)
Makes three big improvements to the CreateObject decode path:
1. Extract AnimPartChanges from the ModelData section instead of
skipping them. Each change is (PartIndex, NewModelId); the server
uses these to replace base Setup parts with armor/clothing/statue
meshes. The player character has ~34 of them on a normal login.
2. Flow AnimPartChanges through WorldSession.EntitySpawn into
GameWindow.OnLiveEntitySpawned, which now patches the flattened
Setup's part list BEFORE uploading GfxObjs. Patching is a simple
"parts[change.PartIndex] = new MeshRef(change.NewModelId, oldTransform)"
keeping the base Setup's placement transform but swapping the mesh.
3. Read the WeenieHeader Name (String16L) that follows the PhysicsData
section. Required walking past every remaining physics flag (Parent,
Children, ObjScale, Friction, Elasticity, Translucency, Velocity,
Acceleration, Omega, DefaultScript, DefaultScriptIntensity) plus the
9 sequence timestamps (2 bytes each) plus 4-byte alignment. The
Name field is then the second thing in the WeenieHeader after
u32 weenieFlags.
Critical bug fix in the same commit: ACE's WritePackedDwordOfKnownType
STRIPS the known-type high-byte prefix (e.g. 0x01000000 for GfxObj ids)
before writing the PackedDword. The first version of AnimPartChange
decoding called plain ReadPackedDword, so it got 0x0000XXXX instead of
0x0100XXXX and every GfxObj dat lookup silently failed — the drop
counter showed 19+ noMeshRef drops including +Acdream himself.
Added ReadPackedDwordOfKnownType that ORs the knownType bit back in
on read (with zero preserved as the "no value" sentinel). After the
fix, noMeshRef drops = 0 across a full login.
LIVE RUN after all three changes:
live: spawn guid=0x5000000A name="+Acdream" setup=0x02000001
pos=(58.5,156.2,66.0)@0xA9B40017 animParts=34
live: spawn guid=0x7A9B4035 name="Holtburg" setup=0x020006EF
pos=(94.6,156.0,66.0)@0xA9B4001F animParts=0
live: spawn guid=0x7A9B4000 name="Door" setup=0x020019FF
pos=(84.1,131.5,66.1)@0xA9B40100 animParts=0
live: spawn guid=0x7A9B4001 name="Chest" setup=0x0200007C
pos=(78.1,136.9,69.5)@0xA9B40105 animParts=0
live: spawn guid=0x7A9B4036 name="Well" setup=0x02000180
pos=(90.1,157.8,66.0)@0xA9B4001F animParts=0
live: spawn guid=0x800005FD name="Wide Breeches" setup=0x02000210
pos=no-pos animParts=1
live: spawn guid=0x800005FC name="Smock" setup=0x020000D4
pos=no-pos animParts=1
live: spawn guid=0x800005FE name="Shoes" setup=0x020000DE
pos=no-pos animParts=1
live: spawn guid=0x80000697 name="Facility Hub Portal Gem"
setup=0x02000921 pos=no-pos animParts=0
live: spawn guid=0x7A9B404B name="Nullified Statue of a Drudge"
setup=0x020007DD pos=(65.3,156.8,72.8)@0xA9B40017 animParts=1
summary recv=60 hydrated=43 drops: noPos=17 noSetup=0
setupMissing=0 noMesh=0
The statue's exact data is now known and the hydration path runs
without errors. The user's "look at the Name field in the CreateObject
body" insight turned this from an unbounded visual hunt into a targeted
grep of ~60 log lines.
Tests: 77 core + 83 net = 160 passing (offline suite unchanged).
Live handshake + enter-world tests still pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 4.7g visual verification turned up several issues and I needed
better visibility into what's actually streaming from the server.
Removes the "suppress after 10" limit on the spawn log and adds
drop-reason counters so I can distinguish "inventory item with no
position" (expected, ~16 per login) from "setup dat missing" and
"zero mesh refs" (unexpected failures).
Findings from a full live run with the new logging:
live: summary recv=60 hydrated=44 drops: noPos=16 noSetup=0
setupMissing=0 noMesh=0
Meaning: every positioned CreateObject is hydrating cleanly into
IGameState. The only drops are the 16 inventory/equipped items that
the server sends without a position (they inherit from their wearer).
The foundry statue is NOT being silently dropped at the codec layer —
it's in our render list somewhere, probably indistinguishable from
generic naked humanoids because we don't decode ObjectDesc yet.
User observations from the visual verification:
* NPCs + +Acdream visible, but naked (no clothing/armor)
* Doors now exist (Phase 4 win over offline-only)
* Portals render as black squares
* Foundry statue not identifiable (most likely a generic-looking
spawn due to missing ObjectDesc)
* Holtburg town crier sign half underground (small Z offset)
All of the "wrong appearance" findings trace back to the same root
cause: CreateObject.TryParse skips past ModelData without extracting
the palette swaps, texture changes, and animpart changes that define
each entity's unique visual presentation. Base setup mesh renders
as-is. Phase 5 work.
Next step in this session: port the ModelData parser (primarily the
AnimPartChanges list — replacing body parts with armored/statue
versions is the single biggest visual improvement for a character model).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The end-to-end pipeline. acdream can now connect to a live ACE server,
complete the full handshake + character-select + enter-world flow, and
stream CreateObject messages straight into the existing IGameState and
static mesh renderer. Gated behind ACDREAM_LIVE=1 so the default
offline run path is untouched.
Added:
- AcDream.Core.Net.WorldSession: high-level session type that owns a
NetClient, drives the 3-leg handshake, parses CharacterList, sends
CharacterEnterWorldRequest + CharacterEnterWorld, and converts the
post-login fragment stream into C# events. State machine:
Disconnected → Handshaking → InCharacterSelect → EnteringWorld →
InWorld (or Failed). Public API:
* Connect(user, pass) — blocks until CharacterList received
* EnterWorld(user, characterIndex) — blocks until ServerReady
* Tick() — non-blocking, call per game-loop frame
* event EntitySpawned
* event StateChanged
* Characters property (populated after Connect)
- NetClient.TryReceive: non-blocking variant that returns immediately
with null if the kernel buffer is empty. Enables draining packets
per frame from the main thread without stalling.
- GameWindow live-mode hookup:
* AcDream.Core.Net project reference
* TryStartLiveSession() called after dat hydration, gated behind
ACDREAM_LIVE=1 + ACDREAM_TEST_USER/ACDREAM_TEST_PASS env vars
* Subscribes EntitySpawned to OnLiveEntitySpawned
* Calls Connect() then EnterWorld(0) synchronously on startup
* OnLiveEntitySpawned hydrates mesh refs from the Setup dat
(same SetupMesh.Flatten + GfxObjMesh.Build + StaticMesh.EnsureUploaded
path used by scenery), publishes a WorldEntitySnapshot via
_worldGameState.Add + _worldEvents.FireEntitySpawned, and
appends to _entities so the next frame picks it up
* OnUpdate calls _liveSession?.Tick() each frame
* OnClosing disposes the session
* Position translation: server sends (LandblockId, local XYZ +
quaternion); we map landblock to world origin relative to the
rendered 3x3 center, add local XYZ, translate AC's (W,X,Y,Z)
quaternion wire order to System.Numerics.Quaternion (X,Y,Z,W)
LIVE RUN OUTPUT (ACDREAM_LIVE=1 against localhost ACE, testaccount):
[dats loaded, 1133 static entities hydrated]
live: connecting to 127.0.0.1:9000 as testaccount
live: entering world as 0x5000000A +Acdream
live: in world — CreateObject stream active (so far: 0 received, 0 hydrated)
live: spawned guid=0x5000000A setup=0x02000001 world=(104.9,15.1,94.0)
live: spawned guid=0x7A9B4013 setup=0x0200007C world=(135.7,9.9,97.0)
live: spawned guid=0x7A9B4014 setup=0x0200007C world=(132.5,9.9,97.0)
live: spawned guid=0x7A9B4015 setup=0x020019FF world=(132.6,17.1,94.1)
live: spawned guid=0x7A9B4016 setup=0x020019FF world=(136.3,5.2,94.1)
live: spawned guid=0x7A9B4017 setup=0x020019FF world=(104.1,31.0,94.1)
live: spawned guid=0x7A9B4037 setup=0x02000975 world=(109.7,33.0,95.0)
live: spawned guid=0x7A9B4018 setup=0x020019FF world=(110.9,31.0,94.1)
live: spawned guid=0x7A9B4019 setup=0x020019FF world=(107.5,31.5,94.1)
live: spawned guid=0x7A9B403B setup=0x02000B8E world=(150.5,17.9,94.0)
live: (suppressing further spawn logs)
First line: +Acdream himself. setup=0x02000001 is ACE's default humanoid
player mesh. world coords match Holtburg (landblock 0xA9B4 local
space). Subsequent spawns are weenies at various setup ids — likely
the foundry statue, street lamps, drums, etc. The 0x7A9B4xxx GUID
pattern is ACE's convention: scenery-type (0x7) + landblock (0xA9B4) +
per-object index.
All spawns flow through the SAME SetupMesh/GfxObjMesh/StaticMeshRenderer
pipeline used by scenery and interiors today. The plugin system's
EntitySpawned event fires on every new entity, so plugins can see
them without any networking awareness.
Tests: 160 passing offline (77 core + 83 net). The live handshake and
enter-world tests are gated and still pass when ACDREAM_LIVE=1.
User visual verification is the final acceptance for Phase 4. Run
with ACDREAM_DAT_DIR + ACDREAM_LIVE=1 + ACDREAM_TEST_USER=testaccount
+ ACDREAM_TEST_PASS=testpassword and look for +Acdream's model + the
foundry statue standing on top of the Holtburg foundry.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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).
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Minimal Silk.NET window wiring: 1280x720, OpenGL 4.3 core profile,
VSync, dark navy clear color, Escape to close. No rendering beyond
the clear call — terrain and shader land in Tasks 8 and 9.
Manual smoke: process starts, stays alive past GL context creation,
produces no stderr, no uncaught exceptions. Actual visual check
will happen end-to-end after Task 10.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>