setAllPoints was honoured for regions and ignored for frames — 139
declarations across 53 files. An unanchored frame falls to the
centre-on-parent default with no size of its own, so its centre is the
screen's centre, and everything hung off it lands there too.
That is why the player's name was drawn in the middle of the world:
PlayerName anchors to the centre of its parent plus fifty, and two frames
above it said setAllPoints and were heard by nobody.
A <Font> is not a widget — it is a named set of type settings that font
strings inherit by name, and SetFontObject already read height and colour
off one. The emitter ignored the element, so all 42 of FrameXML's font
objects were undefined and every label that inherits one fell back to a
default size and colour this client had guessed at.
Inheriting copies the base first so anything stated after it wins, which is
the order a frame's template follows and the order FontStyles.xml relies
on: GameFontNormal is SystemFont_Shadow_Med1 with its own height.
A slider shares a status bar's range and value but is dragged rather than
filled. It follows the cursor for as long as it is held — the one widget
where what happens between press and release is the point — and keeps the
grab when the cursor leaves it, because letting go of a scroll bar by
sliding sideways is not what anyone means.
OnValueChanged fires on each change, which is what a scroll frame listens
to; without it the grip would move and nothing would scroll. A step rounds
to it. Vertical sliders measure from the far edge, since the tree's y grows
upward and a scroll bar's full value is at the top.
The range comes from the XML before the value does, or the value is clamped
against a default range it was never meant to sit in. ThumbTexture is a
region like the button art, handed to its setter once built.
An unnamed frame has no name to lend. Outside a template the emitter
already walked up to find one; inside a template it asked the immediate
parent for its name at replay time and got nil, so
PartyMemberPetFrameTemplate — which buries its $parentName two unnamed
frames deep — created a region called "Name" where the OnLoad two lines
later looks for PartyMemberFrame1PetFrameName.
The region still belongs to the frame containing it. Only the name comes
from further up.
This was invisible until the fallback stopped answering for names carrying
an index: the missing region used to come back as a no-op object and
SetText on it quietly did nothing.
id="1" is how a frame in a numbered set knows which one it is, and neither
the emitter nor the widget API supported it at all — GetID and SetID simply
did not exist. FrameXML concatenates the result straight into a name:
PartyMemberFrame_RefreshPetDebuffs reaches for
_G["PartyMemberFrame" .. self:GetID() .. "PetFrame"], so every one of the
848 ids declared across 57 files was looking up the wrong frame.
Zero when unset, which is what the real client answers and what FrameXML
concatenates without checking.
OnLoad ran at the end of every template body as well as the frame's own, so
ChatFrameEditBoxTemplate's handler fired before the edit box's own OnLoad
had set self.chatFrame — the first thing that handler indexes. A template
installs the script; the frame it is applied to runs it, after its own body
has had its say. It also runs whether or not the frame declared a Scripts
block of its own, because the handler may have come from the template.
GetSpellTabInfo returns six values. FrameXML keeps the last two — with
ShowAllSpellRanks off, which is the default, SpellBook_GetTabInfo throws
away the first offset and count — so four left numSpells nil and the page
count divided by nothing.
GetSkillLineInfo takes a nil index as a question it can answer with nil
rather than one worth raising over; SkillFrame asks it with no selection
during its own load.
GetScreenResolutions and GetCurrentResolution, which UpdateMenuBarTop reads
together and immediately divides. Counts for display channels, dungeon
level and sound drivers, each of which bounds a loop written as num-1.
parentKey="icon" means the owner can say self.icon rather than looking the
name up, and FrameXML's handlers do exactly that — QuestHonorFrameTemplate's
OnLoad opens with self.icon:SetTexture(...). The emitter ignored the
attribute entirely, so all 242 of those fields were nil across 31 files,
which is most of what was still failing inside generated frame code.
Bound before a template applies, so a template body reaching back through
its parent for a sibling finds it already there. Written in brackets
because the key is arbitrary text and one that happened to be a Lua keyword
would not parse.
The virtual branch returned before inherits was ever emitted, so a template
built on another silently dropped its base. 217 of FrameXML's virtual
frames inherit one, and every single one arrived without it.
InterfaceOptionsListButtonTemplate is built on OptionsListButtonTemplate,
so it came with no highlight texture and no size — GetHighlightTexture()
answered nil, OptionsListButton_OnLoad died on it, and the list sizing
itself by its first button's height divided by zero again.
The inherited template applies first, so the template's own body still
overrides what it inherited.
VideoOptionsFrameCancel anchors to $parentApply — the Apply button beside
it on the frame holding both, not a child of the Cancel button. Resolving
that against the button's own name produced VideoOptionsFrameCancelApply,
which nothing is called, so the anchor fell back to the parent and the
button sat in the wrong place. Regions are the other way round, and keep
naming themselves after the frame that owns them.
An anchor's relativeTo also resolved through a hardcoded "self", which
inside a template asked the frame itself rather than whatever owns the
anchor. It now goes through the owning expression, so a template root's
$parent reaches its parent.
<NormalTexture>, <HighlightTexture>, <ButtonText> and their siblings are
regions like any other, declared as their own element with an implied draw
layer and a setter to call afterwards. The emitter ignored every one of
them, so the names they declare never existed — including the
_G["DropDownList1Button1NormalText"] that UIDropDownMenu reads in its own
OnLoad, and with the API fallback on that lookup answers with a function
rather than failing where the cause is. HighlightTexture alone appears in
62 FrameXML files.
Highlights go on the highlight layer and labels on OVERLAY, so a button's
text is never hidden under its own face.
Give frames real setters and getters for those slots too. The PascalCase
catch-all would have answered SetNormalTexture with a no-op, which is worse
than erroring: the setter appears to work and the getter returns nil, so
GetNormalTexture():SetVertexColor(...) — how FrameXML greys out an unusable
action — fails far from the cause. SetText on a button now reaches its font
string rather than doing nothing.
A child named $parentScrollBar was emitted with the template's own name
baked in, so every scroll frame created a global literally called
UIPanelScrollFrameTemplateScrollBar, each overwriting the last, while the
_G[self:GetName().."ScrollBar"] its own handlers look up never existed.
Regions already resolved this at replay time; nested frames did not.
Indexing the missing name hit the API fallback and got a no-op function
back, which is the "attempt to index (a function value)" shape behind 38
of the 67 files that would not load.
Restore newproxy, which is not unsafe despite sitting on that list: it
returns a userdata with a fresh metatable and reaches nothing else, and
Blizzard's RestrictedFrames builds its secure frame handles on it.
Alias math, string and table onto the bare globals WoW's Lua exposes, plus
PI, wipe and strtrim. FrameXML calls min, ceil and PI at file scope, where
one nil name loses the entire file.
Asked Lua itself whether the output compiles, one real file at a time, instead
of only checking its shape against cases I had thought of. That found two faults
the unit tests could not have, and both lose a whole file rather than degrading.
An empty function attribute — <OnMouseWheel function=""/> — passed the
present-or-not check and emitted SetScript("OnMouseWheel", ), which is a syntax
error. Six files carried one.
And every widget was declared as a local. Lua allows 200 per function, and a
large file declares far more: FriendsFrame and InterfaceOptionsPanels both went
over, and the chunk simply refuses to compile. Temporaries now live in one
table, so there is a single local however many widgets a file declares.
140 of 140 FrameXML files now produce Lua that compiles, up from 132 before the
first of these and considerably fewer before the second. The checker is kept as
a tool, because the question it asks is worth being able to ask again.
The first real attempt got 13 Lua files and 11 XML files in, and 115 failed. The
log named two causes and both are fixable.
A Script element inside XML says MovieFrame.lua while the file on disk is
movieframe.lua. The manifest's own files were being resolved without regard to
case; the files those files reference were not, so the fix stopped one step
short and took out most of FrameXML a referenced script at a time.
And every inline handler failed the moment it touched its own argument. Blizzard
writes these bodies using the argument names directly, without declaring them —
an OnUpdate says elapsed, an OnClick says button — and they were being passed
positionally as arg1..argN, so those names were nil. Arithmetic on a nil elapsed
was the loudest of it, from the micro button bar, once per frame. Each handler
now takes the parameters its body expects.
Not fixed, and expected: attempt to index local 'cooldown' (a function value).
That is the missing-API fallback doing exactly what it says — handing back a
no-op where a frame was wanted. It is the cost of getting this far and the
reason the switch is opt-in.
FrameXML inherits a shared font object more than three thousand times, and they
were empty tables — so inheriting one changed nothing and every label came out
the same size in the same colour. GameFontNormal and GameFontNormalSmall are not
decoration; they are how a heading is told from a footnote.
They now carry a height and a colour, with Blizzard's values: normal the
familiar gold, highlight white, disabled grey, the quest fonts near-black for
parchment. SetFontObject applies them, SetFont takes a height, and SetTextColor
is bound at last — the XML emitter has been generating calls to it since
backdrops went in, and they were landing on the metatable's catch-all no-op.
A FontString's inherits names a font object rather than a template, which is a
different thing from a Frame's inherits and had to be emitted differently.
Textures keep the template reading.
The renderer scales text to the requested height. One face is in the atlas, so
this sizes it rather than swapping it; loading FRIZQT__ properly needs a font
atlas rebuild, which cannot happen while a frame is being built, and that is a
piece of work in its own right.
Two of the types FrameXML leans on hardest. A backdrop is the bordered panel
nearly every window in the original interface is built from, and StatusBar is
health, mana, cast bars and experience all at once — without them a panel comes
out as a flat rectangle.
The edge file is a strip of eight square tiles. That is measured, not assumed:
UI-Tooltip-Border is 128x16 and UI-DialogBox-Border 256x32, both exactly eight
wide. Edges are drawn before corners so a corner is never clipped by the run it
meets, and the background sits inside the insets so it cannot show through the
border over it. A frame carrying either now draws, beneath its own regions,
because those sit a level above it.
The shadowing problem that cost two rounds is now structurally impossible rather
than something to keep noticing. Checking the C binding table against the Lua
definitions caught SetBackdrop and both its colour setters about to be lost the
same way EnableMouse was — so the bindings are simply re-applied after the
bootstrap Lua runs. A no-op that answers and does nothing is much harder to spot
than one that errors, and this was the fourth instance.
Adding a StatusBar to the demo found an emitter bug: a nested frame's anchors
were resolved against UIParent instead of the frame containing it, so anything
inside a panel was positioned against the screen. FrameXML nests constantly.
Inside a template the container is not known until replay, so the parent is
asked for then.
Still 228 of 228 real XML files parsing; four more widget tests and two more
emitter tests.
The reader is wired in. An addon's .xml files are parsed, what they declare is
built, and their includes and scripts are followed — so an addon that puts its
layout in XML, which is most of them, now works instead of loading successfully
and drawing nothing.
Order is not the order the emitter reports things in. Includes carry the
templates a file inherits from and scripts define the functions its handlers
name, so both run before any frame is built. Include depth is bounded, because
a file that includes itself would otherwise recurse until the stack gives out.
Writing an addon in XML found a real bug in the emitter. $parent inside a
virtual frame was being resolved against the template's own name, so a region
declared as $parentBg became MyTemplateBg — the same name for every frame
inheriting that template, rather than FooFrameBg on each. Inside a template the
owning frame is not known until it is replayed, so the name is now built then,
from the real frame's. Outside one it stays a literal, where deferring it would
only make the generated code harder to read.
addons/WoweeXmlDemo is written the way FrameXML writes things: a Script element
pulling in its handlers, a virtual template supplying a backdrop, a frame
inheriting it, regions named with $parent, one FontString anchored to another
by name, and scripts bound both by name and as inline CDATA.
Still 228 of 228 real XML files parsing, and two more tests pin the $parent
rule in both directions.
The XML half of the interface. 144 files in FrameXML define frames that Lua
only fills in, and most real addons put their layout there too — which is why
loading one could report success and draw nothing.
The loader emits Lua rather than building widgets from C++. Doing it the other
way would have meant a second implementation of everything CreateFrame already
does — parenting, naming, templates, script binding — kept in step with the
first by hand. This way XML frames and hand-written frames travel one path, so
anything fixed for one is fixed for both, a template declared in XML is usable
from a script without translation, and the emitter's output is a string a test
can read without a Lua state.
Covered: frames and their types, Size and Anchors in both the AbsDimension and
attribute spellings, Layers with Textures and FontStrings, nested Frames,
Scripts as inline CDATA or a named function, virtual frames as templates with
inherits applying several in order, and $parent expansion — which nearly every
region in the original interface depends on for its name.
The parser handles what FrameXML actually contains rather than what XML permits:
CDATA taken verbatim, because it holds Lua and decoding entities inside it would
corrupt every comparison; entities decoded everywhere else; comments and the
declaration skipped; both quote styles. Malformed input is reported rather than
thrown, so one bad file cannot take the rest of the interface down.
Run over the real data, all 228 XML files under Interface parse — the whole of
FrameXML and every Blizzard addon — emitting 2.7 MB of Lua with two warnings.
Thirteen tests cover the parser and the emitter directly.
This is the reader, not yet the bootstrap: nothing calls it during addon load
yet, and the API surface FrameXML expects is a separate and much longer job.
Drawing was half of it. A frame that cannot be clicked is a picture, and every
interesting addon — a bar you drag, a button you press, anything with a tooltip
— needs the other half.
Frames now hit test, and the rule is that whatever is visibly on top is what
gets the click: the same comparison the draw order uses, read the other way
round, so the last thing painted is the first thing hit. Regions are never
targets, matching WoW, where a texture is not clickable and its frame is. A
plain Frame is transparent to the mouse until EnableMouse, which is what keeps
a container from swallowing clicks meant for what is underneath it; a Button
switches it on for itself.
OnEnter and OnLeave fire from hover changes, OnMouseDown and OnMouseUp from the
button, and a click is a press and release on the same frame — so sliding off a
button to change your mind does not count, as it should not.
The client's own interface keeps first claim: input only reaches addon frames
when ImGui does not want the mouse, so an addon frame under an open window
cannot steal from it. Layout runs before the hit test, or clicking a frame that
moved this frame would use where it used to be.
Seven more cases cover the rect bounds, mouse-disabled and hidden frames, a
child taking the click from its parent, a zero-sized frame never being hit, and
overlapping frames resolving by strata and then by creation order.
WoweeWidgetDemo now lights its highlight on hover, deepens it while held, and
counts clicks — through the ordinary script API, nothing special-cased.
CreateFrame answered and events dispatched, but CreateTexture handed back a
table whose every method was a no-op — SetTexture, SetPoint, SetVertexColor, all
of them, with a metatable turning any remaining capitalised name into another
no-op. An addon could be written, loaded and run without ever putting a pixel on
the screen. The API looked supported and nothing happened.
There is now a real retained tree behind it. Frames and regions are still Lua
tables, so existing addon code is untouched, but each carries a handle into C++
state that holds its geometry and its art, and a renderer walks it.
The layout is WoW's: anchors as constraints rather than positions. An anchor
says "this fraction of my rect sits at that point", so one anchor plus a size
places a frame and two opposing anchors give the size as well — which is how
SetAllPoints works, and how most of FrameXML sizes its backgrounds without ever
stating a size. Draw order is strata, then frame level, then layer, then
sublevel, then creation order, so a child draws over its parent and an OVERLAY
in a low stratum still falls behind a BACKGROUND in a high one.
Coordinates are kept in WoW's convention throughout — origin bottom-left, y
upward — and flipped once at the point of drawing, so every anchor rule reads
the way Blizzard documents it rather than mirrored.
The tree is deliberately free of Vulkan and ImGui so the layout rules are
testable without a device; thirteen cases cover the anchor solver, inheritance
of visibility and strata, and the draw ordering. Rendering is separate and
loads Interface art through the existing asset path, the same route the action
bar already takes for its backpack button — the player's own install, nothing
new shipped.
addons/WoweeWidgetDemo is a hand-written addon using only the ordinary API:
anchored art from Interface\Buttons, layer ordering, a sibling-relative anchor
and a slash command. Copy it into the AddOns directory to see it.
This is the piece both goals needed. Addons can draw now, and the same tree is
what FrameXML targets, which is what makes running the original interface
possible rather than imitating it.
There was no way to put a companion away. It has no aura, so nothing appears in
the buff bar to right-click, and pressing it in the spellbook only ever summoned
— producing the same critter again. CMSG_DISMISS_CRITTER was in the opcode
tables and nothing sent it.
A companion announces itself only through UNIT_FIELD_CRITTER on the player, so
that field is now read and the guid remembered along with the spell that called
it, identified the same way the mount aura is: the spell just cast from the
ground, rather than a guess at "some spell", which would fire the toggle on the
wrong button. Casting that spell again dismisses instead of summoning.
The local state is left alone until the server clears the field, so a dismiss it
refuses does not leave the client believing the companion is gone.
WotLK only, on both halves: no earlier expansion publishes the field or carries
the opcode. On those the field index resolves to missing and nothing changes.
Tested by pinning the field index against neighbours whose values are
independently known — a wrong index here would not fail, it would quietly read
whatever sits at that offset and hand the dismiss a guid that is not a
companion — and by asserting the older expansions claim no index at all.
My previous attempt at this did not work and CI failed again unchanged on the
same commit. I had assumed glm was vendored under extern/ and that adding
TEST_SYSTEM_INCLUDE_DIRS would supply it. It is not there — extern/ holds
catch2, imgui, lua and the FidelityFX SDK, and glm comes from find_package(glm),
whose include path arrives with the imported target rather than through any
directory this file lists.
I also verified that change the wrong way. Linux resolves glm from /usr/include,
which the compiler treats as implicit, so it compiles whether or not anything
links the target, and the include never appears in the generated flags either.
A local build proves nothing here.
What settles it is CI's own evidence: test_spline includes math/spline.hpp,
which includes glm/glm.hpp, links glm::glm, and compiled on macOS in the very
run where test_hill_climbing failed for want of that header. Linking the target
is demonstrably the mechanism that works there.
Same two-branch check as the top level, since GLM 1.0 may build glm::glm as a
real library and expose headers as glm::glm-header-only.
It was the one target of forty-eight configured with TEST_INCLUDE_DIRS but not
TEST_SYSTEM_INCLUDE_DIRS, which is where extern/ and its vendored glm live.
Harmless while nothing it compiled reached for glm; adding the yaw round-trip
cases to test_movement_limits.cpp pulled in core/coordinates.hpp, which does.
Linux finds a system glm regardless, so only the macOS job ever said so.
Verified from a clean configure rather than the warm build tree that hid it.
The Maiden's Fancy did not line up with the plank at Menethil. Its heading
through the stop came from the leg it arrived on, and a route generally turns as
it passes through its dock — TaxiPath 292 comes into node 5 on a bearing 26
degrees off the one it leaves on. Using only the arrival left the hull half that
turn out of true, 13 degrees, which over a hundred-unit hull is enough to walk
the gangway off the plank while still reading as merely slightly off.
A berth heading is the line the hull lies on while alongside, so take the chord
through the stop: the node before it to the node after it. Where a route ends at
its dock and there is no node after, the arrival leg is still all there is and is
kept as the fallback.
The position was already pinned to the authored node and is unchanged; this was
only ever the heading.
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.
A WotLK MO_TRANSPORT publishes where it is on its route: GAMEOBJECT_LEVEL is
the route's period in milliseconds, and the high int16 of GAMEOBJECT_DYNAMIC is
how far through that period the hull currently is, as a fraction of 65535.
Neither field was being read, so the client animated on a period it worked out
for itself from distance over speed — and when that came out shorter than the
server's, the ferry simply lapped its shore until the schedule caught up.
The phase is a fraction, so it maps onto whatever timeline the client's spline
has without the two periods needing to agree, and it keeps agreeing as the ride
goes on because it advances at the server's rate rather than a derived one.
Both fields are WotLK-only; nothing earlier published a transport's phase. On
those expansions fieldIndex returns 0xFFFF, the clock is never adopted, and the
existing local animation runs unchanged — covered by a test, along with the
wrap and a zero period, which is what every non-transport GameObject reports.
This syncs the cycle, not the position within it. The client still animates one
map's slice of a cross-continent route on its own geometry, so where the hull
sits at a given phase is still the client's own answer; what changes is that it
completes exactly one cycle per server cycle instead of several. Matching the
position too means mapping the phase onto the sub-interval of the full route
that belongs to this map, which is a larger change to how slices are built.
Two things about the Kraken.
It lapped Borean Tundra several times instead of leaving. A cross-continent
route is split into one slice per map and each slice is animated on its own,
sized from its own nodes alone — so its cycle is far shorter than the server's
and the boat simply ferried its shore over and over until the transfer came
due. Each slice now measures the whole route, across every map it touches, and
spends the difference held at the pier: one departure per server cycle, the
boat plainly waiting where a passenger would expect to find it, and never
parked offshore, which is the case the old comment rightly warned about.
And a rider who walked off onto the dock stayed attached to the ship. The
disembark footprint is deliberately generous — larger than any hull — so it
only catches someone clearly away from the ship, and stepping onto the pier
alongside leaves them well inside it. What tells ashore from aboard is having
no deck underfoot, which this was already measuring and using for nothing but
its log line. It now decides, counted over several frames so that jumping is
not mistaken for stepping off.
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.
Two separate causes, both confirmed from the log.
The sails and the paddlewheel were drawn at the middle of the map.
WMORenderer::setM2Renderer is declared and was never called from anywhere, so
m2Renderer_ was null for the process's whole life and every path that touches a
WMO's child doodads sat behind a null check that never passed: the transform
propagation that puts a doodad on its parent, and the three removal paths that
free them. They were created at the origin, never given the hull's transform,
and never cleaned up. The diagnostic added for this said so outright —
worldPos=(0.93,0,0), identical for every ship and every parent instance.
Static world doodads were unaffected throughout, because terrain streaming
places those at their world position itself and never goes through the parent,
which is why this only ever showed on transports.
Separately, the Kraken sailed past its dock and back several times before
settling. A TaxiPath encodes a dock wait as two keys at the same position, and
a Catmull-Rom spline is not constrained to the hull of its control points, so
evaluating through that repeated key overshoots and recovers for the length of
the wait — the same overshoot already documented and clamped for the tram. The
hold that fixes it ran only for the three entries in berthRunsParallel, because
it was written for their broadside berths, and the Kraken is not one of them.
Position holding is not berth-specific; berthRunsParallel now decides only the
heading, which is what it is about.
The dwell test covering this asserted a docked ship keeps whatever rotation was
left over. It now derives the approach heading instead, which is both what
happens and the better answer; the test says so, and a new one pins the hold
across five points of a Kraken dwell and fails under the old gating.
The route yaw is derived on the premise that a transport WMO faces model-space
+X, with a table naming the icebreaker and the night-elf ferry as the two
bow-reversed exceptions. Measuring the art says the premise is backwards and
the exceptions are the models that need none.
Two independent measurements over every .wmo under World\wmo\transports. The
hulls taper to a point at -X and stay blunt at +X — transportship 1.0 against
10.1 half-width at the two ends, the icebreaker 4.1 against 14.8, and the same
for the NE ferry, the UD and pirate ships, both zeppelins and the battleships.
And the icebreaker is a paddle steamer whose ICEBREAKER_PADDLEWHEEL doodad,
which belongs at the stern, sits at x=+36.3 on a hull spanning -60.7..+50.1.
So the offset is PI for all of them and the table is gone. The Bravery, which
the table gave no correction at all, was sailing stern-first as a result.
The table could never have been right, because it was fitted while facing came
from a frozen server yaw rather than from the route — what it was correcting
was not the hull. That also empties the docked-restore-spawn-yaw branch, whose
condition was "bow offset is zero"; it is removed rather than left looking live,
and a docked ship keeps its corrected arrival heading as the parallel-berth
ships already did.
Five tests asserted the old table, including one built entirely around the
per-model distinction. Rewritten against the measurements.
Two yaw bugs, both in the direction the user reported: the Icebreaker lying
across its pier bow-first and unboardable, the Bravery sailing in backwards.
hasServerYaw is set by every server update, including the ones that arrive for
a ship the client animates itself along a TaxiPathNode route. The animator took
it unconditionally and as its first branch, so such a ship's facing froze at
whichever orientation the server last reported — its berth heading — and stayed
there for the whole voyage while the position ran along the route underneath.
That also made everything below it unreachable for any transport the server had
ever mentioned: the route yaw, the per-model bow offset, and the broadside dock
hold were all dead code in practice. Server yaw is authoritative only while the
server is also driving position; when the client owns the animation it owns the
phase, and facing has to come from the route tangent.
The 180-degree correction that sits on top compared the canonical velocity
against (cos s, sin s). A server yaw s points along canonical (sin s, cos s) —
the two components swapped, which is a reflection rather than a rotation. The
dot product it produced was not the alignment of anything: a transport facing
exactly along its travel measured sin(2s), which is +1 near a heading of 45
degrees and -1 near 135, so the check flipped correctly-oriented transports
through 180 degrees purely on which way their route happened to run.
Both tests that covered this encoded the broken convention — one described a
transport moving east while "facing west" using a heading that was actually
perpendicular. Rewritten against core/coordinates.hpp, plus a sweep over 16
headings asserting a transport facing its direction of travel is never flipped;
that sweep fails on the old heading vector.
CMSG_PET_ACTION's action field is a pair, not an id: the high byte says what
kind of action it is and the low 24 bits say which one. Commands and stances
reuse the same small numbers, so 1 is follow as a command and defensive as a
stance, and only the byte above them tells the two apart.
Four callers each read that differently. Dismiss packed action 0 under the
command type, which is COMMAND_STAY — the pet planted itself and stayed. The
Lua bindings had the type and the action swapped. The chat commands sent a
bare 1..6 with no type byte at all. The action bar labelled slots off a flat
1..6 numbering that does not exist on the wire, so it mislabelled the built-in
buttons and left the id-0 ones blank, and the stance row matched stance ids
against command slots.
The encoding now lives in game/pet_action.hpp and every caller builds through
it. Bar slots the server sends down were already packed correctly and still go
back unchanged, which is why clicking the bar worked while nothing else did.
Sorting arranged what was there without consolidating it, so two half stacks
of linen stayed two half stacks — just adjacent. Pour them together first,
then sort, so the result is one stack of twenty rather than two of ten.
On the wire a merge is a swap: dropping a stack onto another of the same item
makes the server move what fits and leave the rest behind. So the merges go
through the same queue as the sort, ahead of it, and the arithmetic mirrors
what the server will do — pour into the earliest partial, stop when the
destination is full, drop the source slot when it empties.
Merging plans and applies in one pass rather than splitting into a compute
and an apply the way the sort does. Those two have to be kept in agreement by
hand, and there is no reason to add a third pair.
Skips what the sort skips: full stacks, items that do not stack, and special
containers. The bank's Sort All merges too.
The conversion between the renderer's character yaw and the game side's
canonical yaw was written as 180 - c. It should be c + 90, which is not a
choice but a consequence: canonicalToRender swaps x and y, and canonical yaw
is atan2(-dy, dx), so a direction at canonical yaw c has render components
(-sin c, cos c) and a render yaw of c + 90. A mirror and a rotation agree at
exactly one heading.
Every user of the pair was wrong in the same way, so nothing looked out of
place while values only moved between renderer and game. It showed when one
crossed to the server: orientation. The arc checks were run against a heading
that was mirrored, which is why Smite reported a target as not in front while
the character plainly faced it, and why casting appeared to face the wrong way
once the combat auto-turn was routed through the same conversion.
Correcting the pair puts the auto-turn back where it was — going through
canonical now produces exactly the render angle it computed directly — and
sends the server a heading that matches what is drawn. The test derives the
expected render yaw from the direction vector rather than restating the
formula, so it fails if the two conventions ever disagree again.
SMSG_ITEM_QUERY_SINGLE_RESPONSE comes in two shapes: some servers put
BuyCount between Flags2 and BuyPrice, some do not, and the client works out
which from the bytes. It decided on InventoryType alone — accept the
five-field reading unless InventoryType came out above 28.
That check cannot see the case it exists for. On a server without BuyCount
the five-field reading lands on AllowableClass, and a class-restricted item's
mask is a small number that passes any range test InventoryType could apply.
Priest-only is 16, which is INVTYPE_CLOAK, so Friar's Robes of the Light
described itself as a back item and compared against the equipped cloak,
while the server went on equipping it to the chest.
Score both readings across the fields that follow instead: InventoryType,
AllowableClass, AllowableRace, ItemLevel, RequiredLevel, and that SellPrice
is not above BuyPrice. Those alone still tie on a robe, because everything
stays plausible under both. What breaks the tie is BuyCount itself — it is
how many the vendor sells at once, 1 for nearly everything, and a layout
read one field short puts a price there instead.
Healing yourself mid-fight meant dropping the target, casting, and picking it
back up again. Retail falls back to the caster when a beneficial spell is cast
with nothing friendly selected.
A heal and a nuke cannot be told apart by effect id or school — both are
APPLY_AURA, both can be Holy. Spell.dbc's EffectImplicitTargetA can: it is
what the spell expects to be aimed at. Verified against the shipped data,
where Flash Heal, Rejuvenation, Mark of the Wild, Arcane Intellect and
Blessing of Might all read 21 while Smite, Fireball, Shadow Bolt and Shadow
Word: Pain read 6. The column moves with the expansion — 82 in vanilla and
Turtle, 86 in TBC and WotLK — so it is added to each layout with the index
checked against that expansion's own file.
Only the "needs an ally" case falls back. A spell that takes either target,
like Dispel Magic, is left alone: choosing between cleansing yourself and
purging an enemy is guessing at what the keypress meant. Self-only spells
already resolve through their range and are untouched.
The Smite fix worked but was a patch on one call site, and it hand-inverted
a conversion that already existed elsewhere — the same mistake that made the
bug possible.
Facing lives in two representations: the renderer holds the character's yaw
in degrees, the game side holds canonical yaw in radians, and the frame loop
converts render to game every frame. That makes the renderer the source of
truth, so setting movementInfo.orientation and sending MSG_MOVE_SET_FACING
is always undone on the next frame. Three places did exactly that: casting
at a target, using a game object, and correcting after the server answers
SMSG_ATTACKSWING_BADFACING. The first was the reported bug; the second is
the same failure waiting for a gather node, which has a cast time; the third
told the server to keep telling us.
They now share GameHandler::faceCanonicalYaw, which turns the character and
sends the packet. Both directions of the degrees/radians conversion move
into core::coords, replacing four hand-written copies — two of which were
inverting the other two from memory — with one documented pair, and a test
that walks a full turn checking they are still inverses.
CharacterFacialHairStyles drives three geoset channels — a beard, and for a
Draenei the face tendrils. The geoset columns were read at 3, 4 and 5. In
every copy of that DBC shipped here, 9-field and 11-field alike, those hold
a constant per race: Draenei rows read 2010429269, 2010429317 and 1903536 on
every variation. Truncated to uint16 and offset by the group they name
geosets like 47033, which no model has, so nothing was ever drawn on any
character's face. The variant numbers are at columns 6-8 — a Draenei female
reads 2 through 8 there across her seven variations, a night elf female
reads zero on all three channels, and a human female varies only the middle
one, which is what a race with earrings and no beard should look like.
The clamp goes with it. Each channel was forced to at least 1, so a zero —
which means this channel has no feature — selected the first variant
instead. That was compensation for the garbage above; with the right columns
it would hand a night elf female a beard.
Fixed in all four layouts and in every reader: the spawner, the composer,
both online-player paths and the paperdoll. A test pins the columns against
the real layout files, since reading the wrong ones fails silently — the
lookup succeeds, the number is nonsense, and the face just comes out bare.
The bank had one Sort button and it sorted everything, which pools every
item into the main slots — the wrong tool for a bag being kept as a
category, since sorting it empties it into the bank proper.
Add sortBankBag and computeBankBagSortSwaps, which order one bag's contents
by the same rule as everywhere else (quality desc, then item id, then stack
size) and address only that bag's container, and put a Sort button on each
bag's header in the grouped view. The existing button becomes "Sort All" so
the difference in scope is legible before it is clicked rather than after.
Both feed the bank's existing swap queue, so the server sees one
CMSG_SWAP_ITEM per frame either way.
Floor selection rejects any surface more than kMaxStepUp above the feet as
unreachable, and kMaxStepUp is 0.60 yards. At the steepest walkable slope
that budget covers a mounted player for about 1/40th of a second: a smooth
frame rises 0.28 yards, a 20 fps frame rises 0.83 and the terrain the player
is climbing stops counting as ground.
From there it compounds. With no floor the player falls, which puts the feet
further below the surface, so the next sample is rejected by a wider margin
than the last. Nothing recovers: the narrow fallback allows 0.5 yards of
penetration and only for 0.10s outdoors, and void recovery does not fire
until 60 yards down — by which point the player has fallen through the hill.
Outdoors the heightfield has one surface per column, so feet below it is
never a valid position. Push back out to the surface when that happens,
which also makes the climb itself smooth: each frame moves, penetrates
slightly, and is lifted clear. Restricted to open ground — not inside a WMO,
no WMO or M2 floor sampled or hinted nearby — because a cave, a tunnel or
Ironforge is legitimately beneath the terrain and must never be yanked up
onto the mountain above it. Bounded below by 0.10 yards so ordinary contact
and one frame of gravity do not trigger it, and above by 12 so anything
deeper is left to the existing void recovery.
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.
The Stranglethorn Vale troll ruins sway like trees because "thorn" is a
foliage token and it sits inside "Stranglethorn". Every StranglethornRuins
piece, and the cliff rocks with it, was classified as foliage and handed to
the wind shader.
Foliage tokens have to be matched as substrings — model names run words
together with no separator, so StranglethornFern01 would not match on a
word boundary — and that is what lets a short token land inside an
unrelated word. An audit of every M2 in the data turned up the same fault
well beyond the reported one: "corn" inside Corner put wind on wall and
arch corners, "hops" inside ShopSign on shop signs, "tree" inside
StreetSign on street signs, "crop" inside Outcrop on rock outcrops, "herb"
inside Herbalism on profession signs, "vine" inside DivineShield.
A list of exceptions would not hold, so rank the matches instead. These
names are head-final compounds — the last word says what the model is — so
the match ending furthest right wins, with a longer token taking a tie at
the same end position, which is how "corner" beats the "corn" inside it.
StranglethornRuins is then a ruin while DustwallowTree, StoneTree and
DeadwindPassRockTree stay trees, none of which a veto list would have
managed. 73 models stop swaying; no plant loses its wind.
isFoliageLike also drives collision and animation, so these props were
walk-through with their animation disabled as well.
The 'steam' substring matches SteamTonk vehicle models. A prior fix gated
it on low-poly geometry (verts <= 200), but the TBC/Turtle SteamTonk
overlay models are tiny proxies that slip under that threshold, so with a
smoke emitter they were mis-classified as additive spell effects again
(glowing translucent). Exclude any 'tonk'/'tank' name outright — those are
vehicles, never steam VFX (real ones are steam/steamgeyser/lavasteam/etc.,
which never carry those tokens). Add a classifier regression test.
Add a Sort button that orders the main bank and bank bags by quality/itemID/
stack (mirroring the backpack sort, one CMSG_SWAP_ITEM per frame), a
"Combine bags" toggle that renders every bank slot as one continuous grid,
and per-slot purchase prices from BankBagSlotPrices.dbc on the bank-bag row
(only the next slot in sequence is buyable; later ones are locked).
Route GameHandler's raid mark getters to SocialHandler, which is where
MSG_RAID_TARGET_UPDATE stores marks — GameHandler kept a second array that
nothing ever wrote, so the target frame, nameplates, minimap and social
panel all read zeros. Fix the parse to match the wire format as well: the
full list carries only the icons that are set rather than a fixed eight,
and a single mark leads with the setter's GUID on WotLK but not on
classic/TBC, told apart by remaining size so every expansion decodes.
Funnel every ship-facing path through TransportManager::transportModelBowOffset
(facing = direction of travel + fixed per-model bow offset), replacing the
scattered per-entry 180-degree hull corrections. berthRunsParallel() names the
side-on dock routes, and buildTaxiSegmentSpline is extracted static/testable so
the cyclic-wrap behavior is unit-covered without a DBC.