My previous attempt moved the portal walk from the character's group to the
camera's, on the reasoning that the frustum belongs to the camera. That did not
fix Ironforge, and it traded one failure for another: a third-person camera
indoors can end up inside a wall or a broom cupboard, and starting the walk
there reaches almost nothing.
Neither position is reliably right. The character can sit in a loose interior
AABB while visually outside; the camera can be somewhere with no useful portal
graph. And at a doorway or in a hallway — where this was reported — they are
simply in different rooms, so no single choice covers it.
Portal culling is an optimization, and the failure it should have is drawing a
room too many. Instead it was deleting the building. So seed the walk from both
groups: a second seed can only ever add groups, never remove one, which makes
this strictly safer than either choice alone.
Ironforge shows it worst because of what rescues everywhere else. Exterior
groups are seeded unconditionally, so in a city WMO the streets and facades stay
drawn however the walk goes. Ironforge is interior throughout, that seed
contributes almost nothing, and the whole result rests on the traversal.
Inside Ironforge the interior emptied out at doorways and hallways.
Portal culling used two different positions. The gate that decides whether to
cull at all tested the camera's containing group; the traversal that decides
which groups survive started from the character's. Each had a reason written
down, and each reason was sound on its own — but between them they left a case
neither covers: camera in one interior group, character in another. The walk
then asks which rooms are reachable from one place while judging the doors by
what is visible from another.
That is not an edge case. It is every doorway and every hallway, which is where
this was reported.
Ironforge shows it worst because of what saves everywhere else. Exterior groups
are seeded into the visible set unconditionally, so in a city WMO the streets
and facades stay drawn however the traversal goes. Ironforge is interior
throughout, that seed contributes almost nothing, and the whole result rests on
the walk.
A portal traversal is a visibility query from the camera, since the frustum it
tests against is the camera's, so it now starts there. The character position
was introduced to stop an orbiting third-person camera culling a building's
interior; the gate above it declines to portal-cull at all when the camera is
not in an interior group, which covers that case, so the workaround has outlived
its cause. The parameter that carried it is gone rather than left accepting a
value nothing reads.
Mounted on a moving boat, the character rendered behind the direction of travel
and off to one side of the mount it was supposed to be sitting on.
The rider is placed at the mount's seat attachment, exponentially smoothed at
14 Hz to damp the jitter of a bone-driven seat while sitting still. The branch
that snaps instead of smoothing keys off `moving`, which reports movement
*input* — and a player standing on a boat presses nothing while the world
carries them at the ship's speed. So the filter engaged and trailed the rider by
its own time constant: about two yards at ferry speed, swinging out to one side
as the hull turned, which is exactly the offset described.
Ask whether the seat is moving in the world instead of whether the player asked
it to. A seat that shifts more than a twentieth of a yard between frames is
being carried; standing on solid ground moves it by a fraction of that, while a
ferry moves it nearly half a yard in a single frame, so the two are an order of
magnitude apart. This covers anything that carries a rider — ships, elevators, a
moving platform — without naming any of them.
The taxi branch above already snapped and is unchanged.
The map worked out the player's zone from geometry alone: of the WorldMapArea
boxes containing them, take the one they sit deepest inside. Those boxes are
axis-aligned rectangles around irregular zones, so neighbours overlap heavily —
the comment on that code already says as much — and the answer is a guess. It
guessed wrong often enough to open on a zone the player was merely near.
The server says which zone the player is in, in SMSG_INIT_WORLD_STATES, and the
client has been tracking it as worldStateZoneId_ for other things all along. It
is the same AreaTable id space WorldMapArea keys on, so it names a zone
directly. Use it, and keep the geometry only for when the server has not said —
before the first world-state packet, or for a zone that is not on the map
currently displayed.
Threaded to the exploration tracker and the player marker as well as the zone
the map opens on. All three answered this question separately, so leaving any
of them on the guess would show as a marker or a revealed area disagreeing with
the zone in front of you.
Tests cover the id winning over the geometry, the id winning even where the
player's own zone box does not reach them, and the fallback for an id that
names nothing on this map.
The Kraken's paddlewheel kept turning while the ship sat at the pier. The
doodad's animation was set once, when it spawned, and never revisited — so
whatever it was given at creation is what it played for the rest of the
session.
Both machinery models carry the sequences for this: ShipStart, ShipMoving and
ShipStop, plus an idle. The animator already knows when a hull is holding at an
authored dock stop, so it records that, and the manager pushes ShipMoving or
ShipStop to the hull's children when it changes. Doodads lacking the sequence
are untouched, which leaves the barrels and lanterns on the same deck alone.
The doodad count is tracked alongside the state because doodads stream in over
several frames: a doodad attached after the last push would otherwise keep the
animation it spawned with, which is the bug again on a smaller scale.
Tests cover the flag going true at the stop and false again on departure — a
stop that never released would simply swap a permanently spinning wheel for a
permanently stopped one.
Boarding the icebreaker left the character running on the spot and unable to
get off. The deck-floor hold is the cause. It exists for one situation — a
continent transfer registers the transport GO before its WMO collision has
finished uploading — and while it is engaged it discards the rider's walking
motion and reapplies the boarding offset every frame.
But the flag is set on boarding and cleared only by a successful deck query, so
boarding somewhere the query never succeeds keeps it engaged forever, and a
gangway that belongs to the pier rather than the hull is exactly such a place.
The rider then cannot move, cannot walk far enough to trigger disembark, and
so cannot leave. The hold now applies only while the hull's collision is
genuinely still loading; once it is there, a query that finds no deck is a real
answer and the flag is released.
Also, addDoodadToInstance silently did nothing when the parent instance was
missing. The M2 has already been created by then, so dropping it there strands
it at the world origin, drawn nowhere and owned by nothing — indistinguishable
from a load failure. It now says so, and a doodad is moved into place as it is
attached rather than waiting for the parent's next transform push, so it is
never drawn at the origin and never stays there if the parent is static.
The machinery diagnostic now reports where the doodad actually ended up.
"Spawned" only ever meant the model parsed.
The sails and the paddlewheel were loading correctly all along — the log says
so — and then every ship was handed the same instance of them.
M2 instances are deduplicated on (model, position): the same model at the same
spot is one placement. That is right for the static world, where a doodad is
created at its world position, and wrong for a transport's children, which are
created at the origin and moved into place a frame later by the parent hull's
transform. Every ship's sails were therefore created at the same spot as every
other ship's, so the second hull of a class got the first hull's instance back.
Both then wrote their transform to the one instance, so it rendered wherever
the last hull updated and nowhere else, and whichever hull unloaded first
destroyed it for the other. The same collapsed the thirteen barrels in a hold
into one barrel. The log shows it plainly: instance 31274 handed out as three
different ships' sails, 31225 as three ships' paddlewheels.
createInstance grows an explicit opt-out for a child created at a placeholder
position, and the transport doodad path takes it. The static WMO doodad path
keeps deduplicating, because it passes a real world position and duplicates
there are genuinely duplicates.
setInstanceTransform also never moved the dedup entry with the instance; only
rebuildSpatialIndex put it right, and that path deliberately avoids the
rebuild. So a placeholder key outlived the placement and kept aliasing. The
entry now moves with the instance, and is only dropped when it still names it.
The penetration rescue added for hill climbing decided "nothing is under
here" from cachedInsideWMO and centerWmoH, and neither can be trusted at the
moment it matters. Containment lags by design, and the WMO sample is cleared
outright for floors more than 12 yards below terrain while containment still
reads false — which is precisely a tunnel mouth being descended. The Darkshire
crypts, the tunnel under the hill into Booty Bay and cave interiors would all
have qualified, and the player would have been lifted onto the hillside above
the entrance they had just walked into.
Ask directly instead, and only once penetration is already established:
- Probe the WMO and M2 floors for anything under the player. Anchored at the
player rather than at the heightfield, because the floor query culls groups
whose top sits more than 4 yards below the probe height — probing from up at
the surface would skip a crypt entirely and report clear ground, the exact
opposite of the truth.
- Skip hole-cut MCNK chunks. getHeightAt interpolates straight across a hole
and reports a surface that is not there, and a hole is how a cave mouth is
opened in the first place, so the structure below may be further down than
the probes reach. Answered per chunk, coarse in the safe direction.
The rescue also logs on the leading edge now, so if it ever does fire
somewhere underground there is a line naming the position rather than a
silent teleport.
isForge was `has(n, "forge")`, so it matched Ironforge — all 64 doodads of
the city, benches and statues and cliffs and elevators among them — and
every batch of each was forced to additive blending. That is the Steam Tank
failure again: a solid model drawn as glowing translucent VFX.
Two faults, both fixed. A forge is a forge only when "forge" is what the
name ends on, digits and separators aside; anything after the token names
something else, so IronforgeBench is a bench, ForgeArms are arms and
CrystalForgeController is a control panel. That leaves 19 real forges.
The additive override was also applied per model, on the premise that a
forge model is nothing but the fire burning in its hearth. It is not: the
forge doodads here run 350 to 2895 vertices and mix body textures
(DALARAN_FORGE01, METALBARSANDSTONE, OM_FORGE_01) with flame cards
(FLAME01, GLOWBALL, LAVALUMP2, FORGECOALS). Only the flame cards need it,
so the override and the black colour-key now key off a per-batch flag set
from the batch's own texture. classifyBatchTexture already knows flame and
glow cards; the forge adds coals, lava and the reflect textures used for
hot metal, which carry no flame token in their names.
Three separate faults were keeping the water's edge dry-looking.
The spray was being painted over. Water moved into a pass of its own today
so the refraction copy could be taken before it, but the swim effects still
recorded into the scene pass, which now runs first — so the droplets ended
up under the sheet. Shallow shore water hid them only partly, because its
alpha sits near the 0.15 floor; the deeper water you swim in hid them
completely. The spray now draws in the continuation pass right after the
water, with its pipelines built for that pass (single-sampled in both
arrangements) and a flag so a mode change cannot record them into a pass
they do not match.
The wading spray never spawned at all. It shared rippleSpawnAccum with the
swimming spray, whose else-branch zeroes that accumulator on every frame it
is not swimming — so a 30/s rate could only ever reach 0.5 in a frame and
never crossed the threshold. It has its own accumulator now. Its travel
direction was also rotated 90 degrees: forward from yaw is (cos, sin), as
the camera controller derives it, not (sin, -cos).
Neither of those puts froth on the water, though, which is what churned
water actually looks like. Wake points are laid down along the path and
handed to the water shader, where they read as aerated white water broken
up by the same cellular octaves the shoreline foam uses. Wading drops one
churned patch per stride; swimming emits a pair off the shoulders that
drift apart as they age, which is the V. Points age out, spread as they go,
and carry a bounding circle so every other water pixel on screen rejects
the trail in one test.
Swimming had a surface spring: whenever no vertical key was held and the
look direction did not say "diving", the swimmer was pulled up at
surfaceErr * 7 plus a constant sink underneath it. A dive therefore only
held while a key was down, and letting go floated you back up.
Retail does not do that. A character can idle underwater and drown doing
it, which a surface spring makes impossible, so the resting state is
neutral: hold depth, with vertical momentum from a jump or a dive bleeding
off rather than reversing.
Buoyancy now only reaches within 1.3 units of the surface, which is what
keeps a swimmer's head out of the water rather than drifting just below,
and it stays out of the way while the look direction says the player is
diving. Space still rises, X still descends, and the surface remains a
ceiling.
Crossing the surface was a step change. The underwater tint did not start
until the eye was 1.5 units down, and when it did it covered the whole
screen at once — so there was a dead zone at the surface, then a pop, and
never a waterline. What the camera showed in between was the water plane
seen edge-on: a razor-straight horizontal cut.
The overlay now takes a boundary rather than covering everything. It runs
from off the bottom of the screen to off the top as the eye moves through
a band around the surface, so the waterline rises past the lens instead of
switching, and the edge carries a small ripple so it does not read as a
straight cut. Fully submerged puts the line past the top, which is the
previous behaviour exactly.
The tint also builds from the surface now rather than from 1.5 down, with
a floor held through the crossing so the submerged half reads as water
rather than clear air.
Both did their jobs. The breakdown showed group creation was 81% of a slow
model load, which is what got split; the counter proved the textures were
already arriving pre-decoded, so the remaining cost was the GPU upload
rather than the decode.
Kept: the terrain phase breakdown and the finalize-step overrun warning,
which between them located three separate problems today and cost nothing
when nothing is slow.
M2_INSTANCES still took 18.6ms after being time-bounded, which means one
createInstance call cost that on its own. It was the bone seed: to avoid a
pop-in flash, a new instance copies bone matrices from an existing
instance of the same model, and it found one by walking the entire
instance list. With tens of thousands of instances resident that is O(n)
per spawn and O(n squared) across a tile.
Remember one seed instance per model instead. Stale entries — the seed
despawned, or its slot now holds a different model — are detected on use
and dropped, falling through to computing the matrices as before. The map
is cleared wherever instanceIndexById is, since it holds instance ids.
The pre-decode hit counter settled it: a transport load reported all 15 of
its textures served from the worker's cache and still spent 40ms in the
texture pass — 2.7ms each, against 0.46ms each for an ordinary model with
the same count. So the cost was never the BLP decode the async path
removes; it is the GPU upload of much larger images, and that runs on the
main thread either way.
Give the texture pass the same treatment the group pass already has: track
the next texture and return InProgress when the budget is spent, resuming
where it left off. At least one texture goes per call, so a model always
makes progress.
This also means my two previous attempts at these hitches were aimed at
the wrong thing — the async path was already delivering pre-decoded
textures correctly.
Transports still spend 40-46ms decoding textures during a model load even
though the async path pre-decodes them on a worker and hands the cache to
the renderer. Two explanations fit: the load is taking a synchronous route
that never sets the cache, or it is set but the lookup keys do not match
what the worker stored, so every texture misses and decodes inline.
Count the textures served from that cache during a load and report it
alongside the timing. Zero on a slow load means the cache is being missed;
a healthy count means the time is somewhere else entirely.
Terrain streaming stalled ~165ms finalising a tile, and the phase timing
put essentially all of it in one WMO_MODELS step: a single model taking
158ms against an 8ms budget. Within that load, group resource creation was
81% of the time — 131ms for 286 groups — with textures at 13% and merged
batches at 6%, so the groups are what had to be split.
loadModelIncremental uploads a bounded slice of groups per call and
returns InProgress while more remain. The accumulating model lives in
loadingModels_ until every group is up, so a half-built model is never
visible to rendering, and its textures and materials are done once and
skipped on resumed calls. loadModel keeps its old all-at-once behaviour
for callers outside terrain streaming by looping until completion.
The result is explicit rather than a bare bool, because the caller has to
tell "more groups to come" from "this model failed" — overloading false
would have spun forever on a bad model.
Terrain finalisation now takes one model per step with a 6ms group budget,
and no longer uploads two models per step when the loader threads are
idle, which had doubled the worst case for nothing.
The validation run is clean, so the instrumentation that found the leak
comes out: the loadModel upload counters, the caller tracking in
createBuffer/destroyBuffer, and the VMA allocation dump.
What stays is the fix — flushDeferredCleanup and the calls to it from the
five subsystems that defer destruction — and tearing the allocator down
under validation, without which the next leak would be invisible in the
same way this one was.
Found it. Subsystems defer resource destruction because in-flight command
buffers may still reference what they are freeing, and the queues drain in
beginFrame when a frame slot comes around again. During shutdown no
further frames run, so everything queued sat there until
VkContext::shutdown dropped the queues unexecuted — deliberately, since by
then the pools those lambdas free sets from are gone.
The result was every resident resource outliving the device.
TerrainRenderer::clear defers each chunk, so at exit 19,984 vertex and
19,984 index buffers leaked, plus 3,276 from WMO groups and the rest from
M2 models and character bones — 426 MB reported object by object at
vkDestroyDevice, which is where the ninety thousand validation errors came
from.
Add VkContext::flushDeferredCleanup, which runs both frame queues
immediately, and call it from terrain, WMO, M2 and character shutdown
right after they queue their destruction and while their descriptor pools
are still alive. That ordering is the whole trick: draining any later
would free sets from pools that no longer exist, which is exactly why
VkContext::shutdown gave up and cleared the queues instead.
Counting model uploads did not find it: the character renderer uploads
nothing this session, M2 uploads are cache-guarded, and every destroy path
reads correctly. So record the return address in createBuffer, drop it in
destroyBuffer, and report what is still outstanding at shutdown grouped by
caller and size.
Addresses rather than symbols, resolved afterwards with addr2line, so
nothing has to be exported at runtime. Only groups of sixteen or more are
listed — the leak is twenty thousand of one size, so it cannot hide.
VkContext::shutdown deliberately skips vmaDestroyAllocator, because
walking every allocation costs seconds with thousands of loaded textures
and models, and the driver reclaims device memory anyway. The cost is
that everything the caches still hold is reported one object at a time at
vkDestroyDevice: ninety thousand errors in a single run, which buries any
real problem the layers find — the rendering faults fixed earlier today
were needles in exactly this haystack.
Keep the fast path for players and destroy the allocator properly when
the layers are active. A few seconds on the way out buys a validation
signal that can be read, and a genuine leak now stands out instead of
hiding in the noise.
The validation log named both faults.
VUID-VkRenderPassBeginInfo-renderPass-00904: the scene continuation pass
was begun against the scene's own framebuffer while declaring a different
subpass dependency, and compatibility is judged against how that
framebuffer was created. An incompatible begin is undefined behaviour,
which is what rendered the frame into a corner with FXAA on. Its
dependency now matches the scene pass exactly; the refraction copy
already issues its own barriers, so the extra transfer stages it declared
bought nothing.
VUID-vkCmdDrawIndexed-renderPass-02684 and
multisampledRenderToSingleSampled-07284: the loading screen drew ImGui
inside the scene pass. That was fine while ImGui's pipelines were built
for that pass, but they are now single-sampled and colour-only for the
overlay pass, so under 8x MSAA a 1x pipeline was being drawn in an 8x
pass with a depth attachment it does not declare. The loading screen now
uses a clearing twin of the overlay pass, which shares its attachments
and framebuffers.
Removes the FXAA guard and its WOWEE_WATER_SPLIT_FXAA override, which
existed only until the cause was known.
Enabling FXAA with the water continuation split active renders the scene
into a corner of the frame. The two interact somewhere I have not found:
FXAA sets its own viewport and scissor before its quad, its targets are
created at the swapchain extent, and the layouts the split leaves behind
are the ones its transition expects — so the fault is not visible in the
code I have read.
Hold the split off on that path for now. Water goes back into the scene
pass there, which means FXAA users get the end-of-frame refraction copy
again and the ghosting that comes with it, but a correct picture. This is
a mitigation, not a diagnosis, and should be removed once the actual
interaction is found.
Water is a large alpha-blended surface whose interior MSAA does nothing
for — it only pays for the samples. It also had to stay inside the
multisampled scene pass, which meant that under MSAA it was still inside
its own refraction copy: the ghost trains and brightness pumping fixed
for everyone else came back for anyone with anti-aliasing on.
Revive the 1x water pass that was retired when water moved into the main
pass. Under MSAA the frame is now scene (multisampled) → resolve → copy →
water at one sample into the resolved image → UI, matching what the
non-MSAA path already does through the scene continuation pass.
setupWater1xPass had no callers at all; it is now built on MSAA change
and rebuilt on swapchain resize, since its framebuffers reference the
swapchain views. It needs a resolved depth buffer to depth test against,
and the pass targets the swapchain directly, so water stays in the scene
pass when depth resolve is unavailable or post-processing renders the
scene off-screen.
water.frag computed its screen-space UVs as gl_FragCoord / textureSize
(SceneColor). That held only while the refraction copy was the same size
as the frame; halving the copy put every lookup at twice the intended UV,
so the coordinates ran past 1.0 and the sampler's wrap turned the scene
into mirrored quadrants meeting at the centre of the screen.
Pass the render target's size in the push constants and divide by that.
It takes the slot the now-unused brightness value occupied — the shader
stopped dividing brightness out when the capture moved ahead of
post-processing.
The refraction copy was taken from the finished frame, which already had
the water composited into it, so the water sampled its own output every
frame. A moving object left one sharp copy per frame — a train of ghosts
rather than a wake — and the display brightness the water divided back
out compounded through the loop, so the surface pumped as the camera
moved.
Draw the water in a continuation of the scene pass instead. The frame now
runs scene, copy, water, UI: the copy is taken at the one point where the
scene is finished and the water is not yet over it. The copy is also
same-frame rather than a frame behind, and being pre-post-processing it
carries no baked-in brightness, so the shader drops both the divide that
compensated for it and the temporal lag.
Two supporting changes. The capture scales with a blit when the scene is
not rendered at the history's resolution, which FSR does — previously the
smaller scene was copied into the corner of a larger history and sampled
as though it filled it. And the history is now half resolution: a quarter
of the pixels copied per frame and a quarter of the sampling footprint,
which is not visible through a surface that displaces the sample by a few
rippling pixels regardless.
Water stays in the scene pass under MSAA, where a continuation pass would
have to resolve a second time; that path keeps the old end-of-frame copy.
The UI now always renders in a colour-only, single-sampled pass opened
after the scene pass closes and after water refraction takes its copy.
Three things fall out of that:
The MSAA fallback is gone. Refraction previously captured after the UI
whenever MSAA was on, because a second multisampled pass would have had
to resolve again and could not preserve the resolved image. A separate
single-sampled UI pass sidesteps that entirely, so the capture is
UI-free at any sample count.
The UI is no longer multisampled. ImGui draws axis-aligned rectangles
and pre-antialiased glyphs, which MSAA does almost nothing for, so it
was paying the scene's sample count for no visible gain.
Both render passes are now built in one place and recreated together.
The previous overlay pass was created in createImGuiResources but not in
recreateSwapchain, so a single window resize silently disabled it.
ImGui initialises against the overlay pass at one sample; its pipelines
stay valid across swapchain recreation because the replacement pass is
render-pass-compatible. Drops endFrameInlineMode_, which tracked which
pass the UI should record into and no longer has a reader.
The landing fix covered one of the two exertion sounds. isJumping() is
!grounded && verticalVelocity > 0, which a character rising out of the
water satisfies the moment the swimming flag clears, so playJump fired
instead — and jumpClips are the character's grunts.
Leaving isJumping() alone: it also gates ship-deck floor snapping, where
reporting a swimmer as not-jumping could snap them up onto a deck. Hold
both exertion sounds for 0.45s after the character leaves the water
instead, which covers the airborne-then-grounded sequence a single-frame
guard missed. The water-exit splash still plays.
The refraction history was copied from the swapchain after ImGui had
rendered into it, so every panel, bag and nameplate on screen was part of
the image the water sampled and appeared smeared across the surface.
The copy cannot run inside a render pass, so close the scene pass after
post-processing, take the capture there, and reopen a render-pass-
compatible overlay pass that loads the swapchain instead of clearing it
for the UI. Compatibility means the ImGui pipelines are unaffected, and
the capture already restores PRESENT_SRC, which is exactly what the
overlay pass expects to load.
The overlay pass is not created under MSAA, where a second pass would
have to resolve again and could not preserve the resolved image; that
configuration keeps the old post-UI capture.
isFalling() was !grounded && verticalVelocity <= 0, which holds for a
swimming character: not on the ground and not rising. Reaching the shore
therefore looked like a fall arriving at the ground, so SfxStateDriver
played a landing — with prevFalling set it took the hard-landing path and
the character grunted on every water exit.
Exclude swimming from isFalling(), and skip the landing entirely when the
previous frame was swimming: wading ashore is marked by the water-exit
splash that already plays, not by a landing thud.
WOWEE_M2_NO_PARTICLES, WOWEE_M2_NO_RIBBONS and WOWEE_M2_NO_SKINNING each
drop one subsystem's draws so an artifact can be attributed to the system
that produces it rather than guessed at from a screenshot. M2Renderer
logs which are active at init, which also identifies whether a running
build contains a given change.
computeBoneMatrices capped every model at 128 bones and the mega bone
SSBO gave each instance a fixed 128-matrix slot, but the vertex shader
indexed bones[boneBase + boneIndex] unbounded. A model with more bones
than that — waterelemental.m2 has 177, and 14214 of the 48466 shipped
models exceed 128, topping out at 315 — had its upper bones read into the
neighbouring instance's matrices, throwing spikes across the world.
Pack per-instance bone ranges at each model's true bone count instead of
a fixed stride, raise the ceiling to 512, and clamp the shader's indices
to the range the instance owns. Packing more than pays for the taller
models: the median model has 3 bones and the mean 64, so the same 32 MB
budget now holds roughly 8200 average instances against the old 4096
fixed slots.
Re-apply the multiplicative brightness overlay (scene*br, no white washout)
and stop it compounding through water refraction. Water refraction samples
a scene-history image captured from the final swapchain, which has the
display brightness baked in; re-applying brightness on the water each frame
fed back through that temporal capture and blew out (a multiply diverges
where the old white-lerp converged). Pass the brightness factor to the
water shader and divide it back out of the refraction sample, so refraction
sees the un-brightened scene and the display still gets a true multiply.
This also fixes the latent darkening feedback (water creeping to black).
No pass restructuring; localized to the overlay + water shader.
Brightness > 1 was implemented as a white fullscreen overlay at alpha
(br-1), so the whole image lerped toward white — desaturating and washing
everything out (fully white at max). Add a multiplicative brightness
pipeline to OverlaySystem using a dst-color blend
(result = dst*src + dst, src = br-1) so the scene is multiplied by br —
true brightening that saturates highlights but keeps color, like a real
brightness/gamma control. Darkening already used a black overlay, which is
a correct scene*br multiply, so it's unchanged.
Reverts the emitter spawn lift, which did not help, and eases the lifespan
cap from 1.5s back to 4.5s. Cutting the life hard kept particles near the
emitter, and near the emitter is exactly where they were never visible, so
this only trims their spread rather than trying to hold them at the wick.
A particle is a point sprite carrying a single depth value, so one born
inside the mesh it belongs to is depth-rejected whole rather than partly
occluded. CHANDELIER01 puts all five candle emitters ~0.17 below the top of
the wax, so its flames were only ever seen once they had drifted metres
clear — which is exactly why shortening their life or holding them still
made them vanish, while letting them rain across the room made them
visible. Measure each emitter against the model's own vertices at load and
raise the buried ones just proud of the surface. Fixtures that already look
right have an emitter in open air and are lifted by zero.
Fires express their flame as particle emitters rather than a glow card, and
the emitter path that turns those into a light was gated on lantern-like
models — so a fireplace full of burning wood lit nothing at all and had no
light to flicker. Open flame now takes that path too, with a wider, oranger
light than a candle wick, and forges are classified as the contained fires
they are. All of it guts with the same slow rhythm as the lanterns.
WMO emissive was a flag tuned for Stormwind's lamp glass, far too bright
for a clock face, so it becomes a level instead. Level 2 keeps normal
daylight shading and adds a warm glow that seeps through the face, fading
up as the scene darkens and wavering on three detuned sines so the flame
never visibly loops. Matched on the MM_CLOCKFACE texture, so every
building sharing that face is lit the same way.
Pin game object M2 models so the 60s unused-model reaper can't evict them
while the player is away, exempt game objects from the adaptive doodad
render distance that collapses to ~200 units in a city, and key GPU cull
results by instance ID so a respawned object can't inherit the verdict of
whatever instance held its array slot two frames earlier.
Relative mouse mode engaged on any button press (including left-click to select),
and the camera rotated on the slightest motion — so a click with tiny jitter
turned the view and shifted the NPC out from under the cursor, making selection
unreliable. Suppress rotation until the drag passes a 5px dead-zone, then rotate
normally; reset on each press and release.
When the world map has a server exploration mask but the viewed zone reveals none
of its overlays (full fog), log once per zone: total mask bits set, the zone's own
AreaBit and whether it's set, and each overlay's first AreaID -> ExploreFlag -> bit
state. Distinguishes 'server never sent the bits' from a client mapping bug for the
Darkshore-stays-dark report. One-shot per viewed zone, so not spammy.
Adds an opt-in world-map layer that marks every rare (rank 4) and rare-elite
(rank 2) creature the client currently has loaded, so a rare that is out shows
as a gold/silver diamond with its name in the current zone. Entities only exist
near the player, so a marker means that rare is genuinely spawned right now.
New RareTrackerLayer follows the existing overlay-layer pattern; the HUD feeds it
the filtered live units each frame. Gated behind a new 'Show Rare Tracker'
Interface setting (persisted), off by default.
Resolve M2 game object render bounds in getRenderBoundsForGuid via
M2Renderer::getInstanceBounds (world-space visual sphere) instead of
falling back to a flat 2.5m sphere floated above the origin. This
centers the pick volume on the actual model, so small GOs like
mailboxes win the nearest-hit test over the wall behind them. WMO
game objects are intentionally left on the fallback — a sphere from a
building's AABB diagonal would swallow nearby objects.
Also draw a cyan ground selection circle for GAMEOBJECT targets, which
previously showed no selection outline at all.