the model pane takes the file's camera, and the who list sorts
- the `<Model>` widget's perspective leg: a pane whose file supplies the camera it selected renders through a camera of its own whose viewport is its own cell of the same tile atlas — the reference's structure, one target, a viewport per widget, depth cleared per widget — with the composite, packer and reaper untouched; the selection is RAW (`0x76cec0` reads the count off `MD20+0x124` and the record at `[model+0x3c4] + idx·0x84 + 0x80`, `cameraLookup` never consulted), so `M2Model::cameras` is the whole table in file order and `benilla-extract m2cam` prints it, reproducing wow-re's numbers to the digit; the camera is two fields because the reference has two — `+0x320` a PENDING index and `+0x31c` the installed one — and while a request is pending the pane paints nothing (`76d5f0`'s draw gate, now the paint list's own filter); an index past the count installs the NULL camera, which IS the orthographic leg every shipped in-game pane takes; the projection is the booths' byte-derived diagonal-FOV matrix at the pane's aspect, and the eye and target ride the model's root transform as `0x718960`'s publish does — so `SetFacing` cancels along with `SetPosition` and `SetModelScale`, a contradiction with a wow-re note that went out as a §5 round and came back agreeing: the up vector `0x7ac640` builds is model-space +Z at roll 0, the very axis the facing turns about, and the note's sentence is scoped to `<PlayerModel>`'s frozen camera; each perspective camera takes a render layer of its own from a pool of eight, the orthographic camera clears the atlas and every perspective one clears only depth; `SetLight` is a typed `CGLight` that folds each intensity into its colour, normalises a direction on write, quantizes to 8 bits, returns before the copy on `SetLight(0, …)` and reads the second intensity at index 8 on a short ambient block; the five fog verbs are built (`ClearFog` is `76f5c5 and [edi+0x3a4],-2`, bit 0 alone), so the `Model` UNBUILT list is empty for the first time since 1134; a pane that arms a light or fog takes a rig from a lazily-grown pool, and the tiles' material twins — never reaped since the cache was written — are; `WOW_PROBE_MODEL_CAMERA=1` reads the renderer's own camera back for the three cancellations, `pass=5 fail=0` (2027)
- the layout cache writes in the shutdown tail: an unlocked chat window's size and position did not survive `/logout` or `/reload` and `benilla-config/layout/` was never created (B353) — the lane was right (1.12.1 persists a chat window through `StartMoving`/`StartSizing` and the engine's userPlaced bit; no `SetChatWindowSaved*` verb exists in the 5875 FrameXML), but `UiLayoutPlugin` hung its flush on `OnExit(InWorld)`, which a `/reload` never leaves, its debounced writer was never registered in `Update` at all, and on the roots where the edge did fire the VM-reading saver raced `end_ui_session`'s VM replacement on the same unordered edge (measured: the placement moves with nothing but registration positions); the reference's UI shutdown `0x490bd0` has an ordered tail — `PLAYER_LOGOUT` (`0x490c2a`) → `layout-cache.txt` (`0x490c79`) → the flat saved file (`0x490c7e`) → the per-addon files → `AddOns.txt` — on the same five roots as SavedVariables, `/reload` included, so `save_now` joins `shutdown_ui_state` in that slot, one call site reached from every root, and its own edges are deleted; the debounce stays as the crash writer and only that, a deliberate divergence, and the chat-look flush next door is pinned `.before(end_ui_session)` (2029); the VM gets its font engine at the LOAD edge: the measurer was seated only from an `Update` system on the first frame the glyph atlas exists, but `ReloadUI()` mints a fresh VM and runs the in-game load in the same exclusive slot, so every `<OnLoad>` and every addon's file scope measured 0 through the whole reload (`LOAD MEASURE ZERO` live, 33.185 after) — `seat_text_measurer` is one construction site called from both edges; the options tabs fit once from `<OnShow>`, the `OptionsTab_FitWidth` OnUpdate poll and its `fitted` latch gone, because `GetStringWidth` answers in the region's own units and this window's 0.78 scale is applied in the window's own `<OnShow>` after every `<OnLoad>` — an OnLoad fit read 73.19 where the settled value is 76.47; the settled widths are unchanged to the last digit (76.467232 / 91.661896) and the one-frame 72/87 flash at every open goes; four comments that still called the text measure a frame late, and seven gossip-suite comments that put `UIPanelScrollFrameTemplate` in our `ScrollTemplates.xml` when it lives in the reference's `UIPanelTemplates.xml`, are corrected (2028)
- the `/who` sort is a seven-key chain, not "order by the clicked column" (B365): the reference keeps seven `{key, dir}` slots at `0xc2817c`, seeded zone → level → class → group → name → race → guild; `SortWho` (`0x5ad890`) promotes the clicked key to the front and flips its direction ONLY when it was already there — Name, Name reverses, Name, Level, Name restores what the first click left — and the comparator `0x5ada00` walks the chain as tie-breakers, most-recently-used first, through an ASCII fold (`A`–`Z` by `+0x20`, not a Unicode one), the three DBC arms comparing the localized name out of `ChrClasses`/`ChrRaces`/`AreaTable` where ours had sorted by raw id, and an unresolved row TIES (its miss jumps to `0x5adbb2`, the chain loop's own `inc esi`); the click fires `WHO_LIST_UPDATE` synchronously through `SignalEvent` at `0x5ad9ed`, so the binding promotes and sorts the snapshot and the handler repaints before the OnClick returns, where ours redrew a tick later — a redraw one click stale reads exactly like a header that does nothing; the chain is process state (initialised once from `0x401666`), so it survives a logout; the rider from the same carve: `SMSG_WHO` has two exits, and with `SetWhoToUI` clear more than three rows go to the frame with nothing printed and three or fewer to chat (`0x5adfcc jl`, `eax = 3`) where we printed every unclaimed answer; and wow-re's §11 round corrected the first build before it landed — the chat lines come out in WIRE order with the total line LAST, because `0x5ae0a1` composes each line inside the parse loop and the `qsort` at `0x5ae0e2` sits past its back-edge (2030)
- two lighting reports, one frame and one bit each: the surfacing hitch (B354) is a one-frame stale atmosphere — `update_time_lighting` is late by construction, waiting on the wire drain, the weather tick and the submersion verdict, and five `Update` systems that read the `WowLighting` it resolves declared no ordering, so the executor handed each of them last frame's value, deterministically and invisibly while the atmosphere drifts a minute at a time; at a submersion crossing it jumps, on the very frame the sky and cloud domes un-hide, so the first dry frame painted `LightParams` 27's underwater sky over a dry world (the clip's blue frame, (58, 137, 173) against the settled (53, 58, 34)) and `tick_clouds`' surfacing full-rebuild ran at the underwater cloud density, 0.0, rebuilding an empty field that crept back over 0.4 s — the fix for the pop-in handing the symptom straight back; `LightingConsumeSet` names the read side of `LightingResolveSet`, tied in one place, and four consumers join it, the FFX haze and warp are ordered after the verdict they had the same hole against, and two tests enforce it — zero ambiguities between the two sets, and a source scan over `benilla-world` that fails any `WowLighting` reader not in the set; `emit_minimap` is left stale knowingly because joining cost a 178th item through the world API wall; `WOW_FORCE_SUB=<frames>` holds the eye verdict submerged and releases it, so the crossing reproduces server-less (2032); the black furniture below a boat's deck (B373): `MeshTag` bits 6..=13 are the exterior payload's ground-shade byte and bits 6..=18 the interior payload's SH-probe slot, and `entity_shade` guarded the overlap by asking whether a part was on the interior classifier's Bake law — one of the TWO populations holding a probe slot; the other is a lit interior MODD prop, which a WMO-display GameObject parents under the net entity so it sails with the deck, putting it inside the light node's descendant walk — so every frame the boat's shade byte was written over each prop's slot, `(slot & 0x1f00) | byte`: a neighbouring prop's light where that row was live, a zeroed row — solid black — where it was not; the guard now asks the payload question (`probe_payload`) at both write sites, the tag's bits and the marker derived from one binding; measured live at Ratchet, 749 of 749 resident interior prop parts renamed before, 0 of 640 after, and both ledger leads refuted at the bytes — the MODD colours are healthy (`floor112` raises even `#010101` to diffuse 112) and the fold is rigid-motion invariant since every 1.12 transport authors zero MOLR lights; a prop's mouseover identity now names its lane, words and probe slot, and `WOW_PICK` prints `model_flags.z` (2031); the sea-crossing probe can now measure the seam a report names — an explicit `WOW_PROBE_DOCK` goes first, boards only once the body stands at the dock, carries a map id, takes only a candidate within boarding distance, and drops onto the deck rather than into the hull, because a taxi node's `z` is the WATERLINE for the sea ferries while their decks stand ~16 yd above it — and the seam re-anchor returns the cycle it moved to or `None`, so a healthy crossing no longer logs the server's 9.7 h uptime as a skew inside a 350 s cycle
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -474,19 +474,22 @@
|
||||
OptionsFrame:SetScale(k)
|
||||
end
|
||||
|
||||
-- The era tab width law (MinimalTab.lua l.7): label width + 40. Era measures at OnLoad;
|
||||
-- our measure round-trip answers a frame late, so OnUpdate polls until the width is real
|
||||
-- (the chat-header converge idiom), then latches. Deliberate divergence: the era
|
||||
-- measures with the MEDIUM font it is about to swap away (an accident of its code
|
||||
-- order, ~6 units of extra padding); we measure the small font the label actually
|
||||
-- wears.
|
||||
-- The era tab width law (MinimalTab.lua l.7): label width + 40, measured ONCE per show —
|
||||
-- the 1.12 CharacterFrameTabButtonTemplate's own seat (its <OnShow> calls
|
||||
-- PanelTemplates_TabResize; decision 1993), and it has to be a show rather than the era's
|
||||
-- OnLoad for a reason that outlives the measure: GetStringWidth answers in the REGION'S
|
||||
-- OWN units, so its number depends on the owner's effective scale (glyph advances snap to
|
||||
-- whole physical pixels, so the same label is not proportionally the same width at two
|
||||
-- scales) — and this window's scale is set by OptionsFrame_UpdateScale in its own <OnShow>,
|
||||
-- after every OnLoad has already run. Fired from the tabs' <OnShow>, which the engine
|
||||
-- runs pre-order behind the window's own, the scale is the drawn one. No poll and no latch:
|
||||
-- the engine's font measurer answers inside the Lua call that asked (benilla-ui
|
||||
-- script/measure.rs), and a re-show re-fits at whatever scale the window then wears.
|
||||
-- Deliberate divergence: the era measures with the MEDIUM font it is about to swap away
|
||||
-- (an accident of its code order, ~6 units of extra padding); we measure the small font
|
||||
-- the label actually wears.
|
||||
function OptionsTab_FitWidth(tab)
|
||||
if tab.fitted then return end
|
||||
local w = getglobal(tab:GetName() .. "Text"):GetStringWidth()
|
||||
if w and w > 0 then
|
||||
tab:SetWidth(w + 40)
|
||||
tab.fitted = true
|
||||
end
|
||||
tab:SetWidth(getglobal(tab:GetName() .. "Text"):GetStringWidth() + 40)
|
||||
end
|
||||
|
||||
-- Each row's OnLoad: remember the key + title, paint the label, size the wash plate —
|
||||
@@ -686,9 +689,10 @@
|
||||
return bottom
|
||||
end
|
||||
|
||||
-- Size the body to that, from the scroll frame's OnUpdate. Frame-late by nature (rects
|
||||
-- resolve after Lua runs, so a reflow answers a frame later — the OptionsTab_FitWidth
|
||||
-- idiom), and write-gated: SetWidth/SetHeight invalidate layout, so an every-frame write
|
||||
-- Size the body to that, from the scroll frame's OnUpdate. Frame-late by nature, and for
|
||||
-- a reason that has nothing to do with the text measure: RECTS resolve after Lua runs, so
|
||||
-- a pass that reads GetHeight() to reflow answers off the previous resolve however it is
|
||||
-- driven. Write-gated: SetWidth/SetHeight invalidate layout, so an every-frame write
|
||||
-- would fight the resolver. UpdateScrollChildRect re-drives the bar through
|
||||
-- OnScrollRangeChanged, which is what shows and hides it (and its trough).
|
||||
function OptionsScroll_Fit()
|
||||
@@ -2098,15 +2102,14 @@
|
||||
Tab1 BOTTOMLEFT → Categories TOPLEFT (6,-3)) — the file-folder read, no dead
|
||||
band above (0981; 0978 floated them at the era's TOPLEFT (32,-27) over a
|
||||
24-unit bare strip, the director's gap report). Era width/text law kept
|
||||
(width text+40, selected white small text at BOTTOM+6 vs gold at BOTTOM+4),
|
||||
(width text+40 measured once per show, selected white small text at BOTTOM+6
|
||||
vs gold at BOTTOM+4),
|
||||
authored STATIC: Game is the only tab with anything behind it, so it wears
|
||||
the selected state permanently and AddOns the unselected one (still dead to
|
||||
clicks; the mixin port comes with an addon list). The PLATE is the 0978
|
||||
mapping, kept: one UI-Character-{Active,InActive}Tab piece, bottom-anchored
|
||||
at the era plate height (26/23), flipped vertically via TexCoords (1.12 only
|
||||
ships bottom-tab plates; the flip turns them top-tab). The era's width law is
|
||||
text + 40, measured at OnLoad — our measure round-trip answers a frame late,
|
||||
so OnUpdate polls until the width is real, the chat-header idiom. -->
|
||||
ships bottom-tab plates; the flip turns them top-tab). -->
|
||||
<Button name="$parentGameTab">
|
||||
<Size><AbsDimension x="72" y="37"/></Size>
|
||||
<Anchors>
|
||||
@@ -2131,7 +2134,7 @@
|
||||
<HighlightFont inherits="GameFontHighlightSmall"/>
|
||||
<DisabledFont inherits="GameFontHighlightSmall"/>
|
||||
<Scripts>
|
||||
<OnUpdate>OptionsTab_FitWidth(this)</OnUpdate>
|
||||
<OnShow>OptionsTab_FitWidth(self)</OnShow>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Button name="$parentAddOnsTab">
|
||||
@@ -2161,7 +2164,7 @@
|
||||
GOLD (not disabled grey) — the director's capture is the call. -->
|
||||
<Scripts>
|
||||
<OnLoad>self:Disable()</OnLoad>
|
||||
<OnUpdate>OptionsTab_FitWidth(this)</OnUpdate>
|
||||
<OnShow>OptionsTab_FitWidth(self)</OnShow>
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1073,8 +1073,10 @@ pub(super) fn seed_ui_fixture(
|
||||
};
|
||||
// The dropdown list open (0992, re-seated onto Camera Following Style by 1649), same
|
||||
// posture as the page fixtures: real CVar set, the live open-select-toggle path. The
|
||||
// list's width settles from its OnUpdate a frame later (the kit's WIDTH SETTLE law) —
|
||||
// inside the capture's settle frames.
|
||||
// list's width lands inside the click that opens it — the stock kit's
|
||||
// `UIDropDownMenu_Refresh` sizes every button from `normalText:GetWidth() + 60`
|
||||
// (`UIDropDownMenu.lua` l.395-422) and the engine's measurer answers that getter in
|
||||
// the call that asked, so there is no settle to wait out.
|
||||
script.register_cvars(crate::cvars::registered_pairs());
|
||||
if let Err(e) = script.run(
|
||||
"ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowControls:Click(); \
|
||||
|
||||
@@ -105,6 +105,7 @@ mod probe_goquest;
|
||||
mod probe_guard_poi;
|
||||
mod probe_mail;
|
||||
mod probe_melee;
|
||||
mod probe_model_camera;
|
||||
mod probe_partner;
|
||||
mod probe_rig;
|
||||
mod probe_service;
|
||||
@@ -133,6 +134,7 @@ pub(crate) use probe_goquest::ProbeGoQuestPlugin;
|
||||
pub(crate) use probe_guard_poi::ProbeGuardPoiPlugin;
|
||||
pub(crate) use probe_mail::ProbeMailPlugin;
|
||||
pub(crate) use probe_melee::ProbeMeleePlugin;
|
||||
pub(crate) use probe_model_camera::ProbeModelCameraPlugin;
|
||||
pub(crate) use probe_partner::ProbePartnerPlugin;
|
||||
pub(crate) use probe_rig::ProbeRigPlugin;
|
||||
pub(crate) use probe_service::ProbeServicePlugin;
|
||||
|
||||
@@ -140,10 +140,17 @@ fn shading_of(mat: Option<&WowModelMaterial>) -> String {
|
||||
let e = &m.extension;
|
||||
let c = m.base.base_color.to_srgba();
|
||||
format!(
|
||||
"wmo {:.0} fade {:.0} class {:.1} tint {:.3},{:.3},{:.3} \
|
||||
"wmo {:.0} int {:.0} fade {:.0} class {:.1} tint {:.3},{:.3},{:.3} \
|
||||
sidn {:.3},{:.3},{:.3} win {:.0} sunsel {:.2} bias {:.0} uv {:.4},{:.4} \
|
||||
clutter {:.1},{:.1},{:.1},{:.1} base {:.3},{:.3},{:.3},{:.3} {:?}",
|
||||
e.model_flags.x,
|
||||
// `int` is `model_flags.z` — the LIGHT-LANE flag, and it was the one row of the packed
|
||||
// uniforms this line did not print. Without `is_wmo` it selects the interior-prop lane,
|
||||
// i.e. "this batch is lit by the SH probe its MeshTag names" rather than by the sky; with
|
||||
// it, the WMO interior batch-class lanes `class` then splits. A hit that reads
|
||||
// `wmo 0 int 1` is asking about a probe slot, and the `slot` reading of the tag beside it
|
||||
// is the one that matters — which is the whole diagnosis of B373.
|
||||
e.model_flags.z,
|
||||
e.model_flags.y,
|
||||
e.tint.w,
|
||||
e.tint.x,
|
||||
|
||||
@@ -57,9 +57,17 @@ enum Phase {
|
||||
}
|
||||
|
||||
/// Yards above the boat's sampled origin the GM drop aims: high enough to clear the deck
|
||||
/// wherever the model origin sits (waterline or deck), low enough that the post-teleport settle
|
||||
/// hold (6 s) + the free fall land well inside a ~20 s dock window.
|
||||
const DROP_HEIGHT: f32 = 10.0;
|
||||
/// wherever the model origin sits, low enough that the post-teleport settle hold (6 s) + the free
|
||||
/// fall land well inside the dock window.
|
||||
///
|
||||
/// **10 was not high enough, and the failure was silent.** A taxi path's node `z` is the
|
||||
/// *waterline* for the sea ferries — every node of path 241 carries `z = 0` — while their decks
|
||||
/// stand ~16 yd above it, so the drop materialised inside the hull and the probe reported
|
||||
/// "boarding missed the window" forever without ever printing a `transport: board`. The zeppelins
|
||||
/// hid it: their nodes sit at deck height (71.08), where 10 yd clears fine. Anything on the boat
|
||||
/// counts as boarding — `owning_transport` walks up to the `Transport` root — so overshooting a
|
||||
/// cabin roof is harmless and undershooting is not.
|
||||
const DROP_HEIGHT: f32 = 25.0;
|
||||
/// Seconds after the deck drop before conceding the boarding failed (settle 6 s + fall + attach,
|
||||
/// with slack) and re-arming for the next docked window.
|
||||
const BOARD_DEADLINE: f64 = 15.0;
|
||||
@@ -70,25 +78,38 @@ const BOARD_DEADLINE: f64 = 15.0;
|
||||
/// the server has already put in visibility range, so a probe body parked anywhere else waited
|
||||
/// forever, printing nothing. That made the instrument unrunnable from a cold login, which is the
|
||||
/// only way an unattended session ever starts it.
|
||||
const BOOTSTRAP_DOCK: [f32; 3] = [-14297.2, 531.0, 8.8];
|
||||
/// `WOW_PROBE_DOCK=x,y,z` — go to *this* dock, unconditionally, before waiting for anything.
|
||||
const BOOTSTRAP_DOCK: [f32; 4] = [-14297.2, 531.0, 8.8, 0.0];
|
||||
/// `WOW_PROBE_DOCK=x,y,z[,map]` — go to *this* dock, before waiting for anything. `map` defaults
|
||||
/// to 0 and **must be sent**: `.go xyz` without one teleports within the map you are *currently*
|
||||
/// on, so a probe that has already crossed to Kalimdor and is then aimed at a Booty Bay dock lands
|
||||
/// at Azeroth's coordinates on Kalimdor — off that map's tile grid, where nothing streams and the
|
||||
/// loading cover can never clear. This probe did exactly that and hung, which is the bug it was
|
||||
/// written to chase, arrived at from the other end.
|
||||
///
|
||||
/// Without it the probe rides whichever cross-continent ferry the login happens to be standing
|
||||
/// next to, which is not a choice at all: the 1.12 fleet's seams are not interchangeable (one
|
||||
/// path crosses mid-cycle, another crosses at the cycle wrap), so "it worked" on the ferry that
|
||||
/// answered says nothing about the one a report names. Overriding the destination is how a
|
||||
/// specific seam gets measured.
|
||||
fn dock_override() -> Option<[f32; 3]> {
|
||||
fn dock_override() -> Option<[f32; 4]> {
|
||||
let raw = std::env::var("WOW_PROBE_DOCK").ok()?;
|
||||
let mut it = raw.split(',').map(|p| p.trim().parse::<f32>());
|
||||
match (it.next(), it.next(), it.next(), it.next()) {
|
||||
(Some(Ok(x)), Some(Ok(y)), Some(Ok(z)), None) => Some([x, y, z]),
|
||||
match (it.next(), it.next(), it.next(), it.next(), it.next()) {
|
||||
(Some(Ok(x)), Some(Ok(y)), Some(Ok(z)), None, _) => Some([x, y, z, 0.0]),
|
||||
(Some(Ok(x)), Some(Ok(y)), Some(Ok(z)), Some(Ok(m)), None) => Some([x, y, z, m]),
|
||||
_ => {
|
||||
warn!("WOW_PROBE_DOCK={raw:?} is not `x,y,z` — ignored");
|
||||
warn!("WOW_PROBE_DOCK={raw:?} is not `x,y,z` or `x,y,z,map` — ignored");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
/// How near [`dock_override`]'s named dock the body must stand before the probe will board
|
||||
/// anything — wide enough to cover a pier and the ferry moored along it, tight enough that the
|
||||
/// login spot on another continent can never satisfy it.
|
||||
const DOCK_ARRIVED_YD: f32 = 200.0;
|
||||
/// How near the body a docked ferry must be before the probe will drop onto it — see the scan.
|
||||
/// Generous enough for a long pier, far short of the next dock on the same continent.
|
||||
const BOARDABLE_YD: f32 = 400.0;
|
||||
/// Seconds the probe watches for a cross-continent transport before sending itself to
|
||||
/// [`BOOTSTRAP_DOCK`] — long enough for a login's object stream to deliver one if we are already
|
||||
/// somewhere it sails from.
|
||||
@@ -118,21 +139,46 @@ fn crossing_probe(
|
||||
let adrift = !transports
|
||||
.iter()
|
||||
.any(|(_, t, _)| t.touches_map(0) && t.touches_map(1));
|
||||
if (forced.is_some() || adrift) && !probe.bootstrapped && now > BOOTSTRAP_AFTER {
|
||||
// An explicit `WOW_PROBE_DOCK` goes FIRST and waits for nothing: the whole point of
|
||||
// naming a dock is to ride *that* ferry, and the login spot very often has a different
|
||||
// one already in range — which the scan below would board within the grace period,
|
||||
// silently measuring the wrong seam. (It did exactly that: a run aimed at Booty Bay
|
||||
// rode the Undercity zeppelin instead.) Only the "no ferry anywhere" fallback waits,
|
||||
// because that one is guessing and should let the object stream finish first.
|
||||
// **Having SENT the hop is not having ARRIVED.** `.go` is a server round trip; the
|
||||
// scan below runs again on the very next frame, when the body is still standing where
|
||||
// it logged in and the old surroundings' ferry is still in range — so it boards that
|
||||
// one and measures the wrong seam, which is precisely the failure the override exists
|
||||
// to prevent. (Aimed at Booty Bay, it rode the Menethil–Theramore ferry.) So while a
|
||||
// dock is named, nothing is boarded until the body is actually standing at it.
|
||||
if let Some(dock) = forced {
|
||||
let here = benilla_assets::coords::bevy_to_wow(player.pos);
|
||||
let (dx, dy) = (here[0] - dock[0], here[1] - dock[1]);
|
||||
if (dx * dx + dy * dy).sqrt() > DOCK_ARRIVED_YD {
|
||||
if probe.bootstrapped {
|
||||
return; // hop sent, still in flight
|
||||
}
|
||||
} else {
|
||||
probe.bootstrapped = true; // arrived; fall through to the scan
|
||||
}
|
||||
}
|
||||
let hop_now = forced.is_some() || (adrift && now > BOOTSTRAP_AFTER);
|
||||
if hop_now && !probe.bootstrapped {
|
||||
probe.bootstrapped = true;
|
||||
let [x, y, z] = forced.unwrap_or(BOOTSTRAP_DOCK);
|
||||
let [x, y, z, m] = forced.unwrap_or(BOOTSTRAP_DOCK);
|
||||
info!(
|
||||
"PROBE crossing: going to the dock ({x:.1}, {y:.1}, {z:.1}) — {}",
|
||||
"PROBE crossing: going to the dock ({x:.1}, {y:.1}, {z:.1}) on map {m} — {}",
|
||||
if forced.is_some() {
|
||||
"WOW_PROBE_DOCK"
|
||||
} else {
|
||||
"no cross-continent transport in range"
|
||||
}
|
||||
);
|
||||
// The map id is not optional — see [`dock_override`].
|
||||
let _ = net.0.send(ClientCommand::Chat {
|
||||
kind: crate::net::ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {z} "),
|
||||
text: format!(".go xyz {x} {y} {z} {}", m as i32),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -150,6 +196,17 @@ fn crossing_probe(
|
||||
if sample.map != map || sample.moving {
|
||||
continue;
|
||||
}
|
||||
// **And it has to be a ferry we are actually standing next to.** A cross-continent
|
||||
// `.go` leaves the departed surroundings' objects in the entity list until the
|
||||
// server gets round to removing them, so the scan can otherwise pick a boat
|
||||
// thousands of yards away and `.go` onto its deck — undoing the hop that just
|
||||
// placed us, and measuring whichever seam that boat happens to cross. Aimed at
|
||||
// Booty Bay, this probe boarded the Tirisfal zeppelin twice that way.
|
||||
let here = benilla_assets::coords::bevy_to_wow(player.pos);
|
||||
let (dx, dy) = (here[0] - sample.pos[0], here[1] - sample.pos[1]);
|
||||
if (dx * dx + dy * dy).sqrt() > BOARDABLE_YD {
|
||||
continue;
|
||||
}
|
||||
let [x, y, z] = sample.pos;
|
||||
info!(
|
||||
"PROBE crossing: boat {:#x} docked on map {map} at ({x:.1}, {y:.1}, {z:.1}) \
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
//! `WOW_PROBE_MODEL_CAMERA=1` — the live probe for the `<Model>` widget's **perspective leg**
|
||||
//! (decision 2027): a plain `CreateFrame("Model")` pointed at a camera-bearing file, framed by the
|
||||
//! file's own camera through a camera of its own into its cell of the tile atlas.
|
||||
//!
|
||||
//! ## What it asks, and why it is a probe rather than a test
|
||||
//!
|
||||
//! The unit tests in `crate::ui_models` pin the leg's arithmetic — the client's diagonal-FOV
|
||||
//! matrix against wow-re's worked numbers, and the three cancellations. What they cannot reach is
|
||||
//! the half that only exists at run time: does the engine resolve the camera at all, does the
|
||||
//! renderer find the record, does a camera get spawned, aimed, and pointed at a real cell of a
|
||||
//! real atlas. So this probe drives the **whole live pipeline** from Lua and reads the result off
|
||||
//! the entities the renderer actually built.
|
||||
//!
|
||||
//! The measurement is numeric, not visual (method.md's rule: a capture can confirm an existence
|
||||
//! fact, it cannot measure one). Each leg projects three model-space probe points through the
|
||||
//! camera and the root the renderer placed this frame, and compares the resulting NDC against the
|
||||
//! baseline's:
|
||||
//!
|
||||
//! - `scale` — `SetModelScale(3)` must not move a pixel. The authored eye is carried through the
|
||||
//! model's root transform, so the camera scales with the model.
|
||||
//! - `position` — `SetPosition(0.4, −0.3, 0.9)` must not move a pixel, for the same reason.
|
||||
//! - `facing` — `SetFacing(1.0)` must not move a pixel **either**. That one is the finding this
|
||||
//! work commissioned (wow-re `modelframe-facing-cancels.md`): the up vector `0x7ac640` builds is
|
||||
//! model-space `+Z` at `roll = 0`, which is the axis the facing turns about, so eye, target,
|
||||
//! geometry and up all turn together. `modelframe-render-law.md` §2's "only `SetFacing` shows"
|
||||
//! is scoped to `<PlayerModel>`'s frozen camera.
|
||||
//! - `ortho` — the control that must NOT hold: `SetCamera(9)` is past the file's camera count, so
|
||||
//! the widget installs the NULL camera and falls to the orthographic leg, where a facing is a
|
||||
//! plain roll in the screen plane. A probe whose "identical" legs all passed because nothing was
|
||||
//! drawing would fail here.
|
||||
//!
|
||||
//! Three screenshots ride along — the baseline, the scaled/offset one and the turned one — as the
|
||||
//! existence evidence and for the director's own eye. They are the whole window, so they are not
|
||||
//! compared: the world behind the pane moves.
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//! ```text
|
||||
//! WOW_USER=probe5 WOW_PASS=pprobe5 WOW_CHAR=Probefive WOW_UNATTENDED=1 WOW_NOSOUND=1 \
|
||||
//! WOW_PROBE_MODEL_CAMERA=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the slot-keyed probe identity — method.md "The local vmangos server"). `WOW_TILE_TRACE=1`
|
||||
//! alongside it prints the leg, the record, the eye and the matrix per pane per frame.
|
||||
|
||||
use bevy::camera::visibility::RenderLayers;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::view::screenshot::{save_to_disk, Screenshot};
|
||||
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use super::probes::ProbeClock;
|
||||
use crate::net::SelfPlayer;
|
||||
use crate::ui_models::{TilePerspectiveCamera, TileRoot, UiModelTiles};
|
||||
|
||||
/// The file the probe frames. `Creature\Wolf\Wolf.m2` carries **two** cameras
|
||||
/// (`benilla-extract m2cam`: index 0 type 0 at eye `(1.6927, 0.7238, 0.8837)` fov `0.95002`,
|
||||
/// index 1 type 1 at eye `(5.5556, 0, 1.8056)` fov `0.67620`), so it exercises the raw-index
|
||||
/// selection AND has an index past the count for the orthographic control.
|
||||
const FILE: &str = r"Creature\\Wolf\\Wolf.mdx";
|
||||
/// The pane's size in FrameXML units — the pet pane's own `318×224`, whose aspect is wow-re's
|
||||
/// worked example (`camera-law.md` §12.1: `θ = 0.287938 · fov`).
|
||||
const PANE_W: f32 = 318.0;
|
||||
const PANE_H: f32 = 224.0;
|
||||
/// Frames to let a change settle before the numbers are read: the extract republishes the request
|
||||
/// on the next conversion and the renderer aims the camera the frame after that.
|
||||
const SETTLE_FRAMES: u32 = 8;
|
||||
/// NDC agreement the cancellation legs must hold to. Generous against f32 round-off through two
|
||||
/// 4×4 composes; a leg that failed to cancel misses by tens of percent, not by a thousandth.
|
||||
const NDC_EPS: f32 = 1.0e-3;
|
||||
|
||||
/// The Lua that builds the pane. Anonymous is the corpus shape (pfUI's autocast shine), but the
|
||||
/// probe needs a handle on it, so it is global.
|
||||
const BUILD: &str = r#"
|
||||
ProbeModelCam = CreateFrame("Model", "ProbeModelCam", UIParent)
|
||||
ProbeModelCam:SetWidth(318)
|
||||
ProbeModelCam:SetHeight(224)
|
||||
ProbeModelCam:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
|
||||
ProbeModelCam:SetModel("Creature\\Wolf\\Wolf.mdx")
|
||||
ProbeModelCam:SetCamera(0)
|
||||
ProbeModelCam:SetLight(1, 0, 0, -0.707, -0.707, 0.7, 1.0, 1.0, 1.0, 0.8, 1.0, 1.0, 0.8)
|
||||
ProbeModelCam:Show()
|
||||
"#;
|
||||
|
||||
pub(crate) struct ProbeModelCameraPlugin;
|
||||
|
||||
impl Plugin for ProbeModelCameraPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<ModelCameraProbe>()
|
||||
.add_systems(Update, model_camera_probe);
|
||||
}
|
||||
}
|
||||
|
||||
/// One leg's reading: the NDC of the three probe points, and the label to report it under.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Reading([Vec2; 3]);
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
struct ModelCameraProbe {
|
||||
phase: Phase,
|
||||
baseline: Option<Reading>,
|
||||
passes: u32,
|
||||
fails: u32,
|
||||
skips: u32,
|
||||
exited: bool,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy, PartialEq)]
|
||||
enum Phase {
|
||||
#[default]
|
||||
Start,
|
||||
/// Waiting for the pane's tile to be packed and its camera aimed.
|
||||
Await {
|
||||
since: f64,
|
||||
},
|
||||
/// A change is in flight; read it in `at`.
|
||||
Settle {
|
||||
frames: u32,
|
||||
leg: Leg,
|
||||
},
|
||||
Done,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
enum Leg {
|
||||
Baseline,
|
||||
Scale,
|
||||
Position,
|
||||
Facing,
|
||||
Ortho,
|
||||
}
|
||||
|
||||
impl Leg {
|
||||
/// The Lua that puts the pane INTO this leg — run when the leg is entered, before its reading.
|
||||
fn setup(self) -> &'static str {
|
||||
match self {
|
||||
Leg::Baseline => "",
|
||||
Leg::Scale => "ProbeModelCam:SetModelScale(3)",
|
||||
Leg::Position => {
|
||||
"ProbeModelCam:SetModelScale(1) ProbeModelCam:SetPosition(0.4, -0.3, 0.9)"
|
||||
}
|
||||
Leg::Facing => "ProbeModelCam:SetPosition(0, 0, 0) ProbeModelCam:SetFacing(1.0)",
|
||||
// The control: index 9 is past Wolf's two cameras, so the widget installs the NULL
|
||||
// camera and the pane drops to the orthographic leg. Its perspective camera goes
|
||||
// inactive, which is what the reading detects.
|
||||
Leg::Ortho => "ProbeModelCam:SetCamera(9)",
|
||||
}
|
||||
}
|
||||
|
||||
fn next(self) -> Option<Leg> {
|
||||
match self {
|
||||
Leg::Baseline => Some(Leg::Scale),
|
||||
Leg::Scale => Some(Leg::Position),
|
||||
Leg::Position => Some(Leg::Facing),
|
||||
Leg::Facing => Some(Leg::Ortho),
|
||||
Leg::Ortho => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The three model-space points the legs are compared on — a wolf-sized spread, in Bevy space
|
||||
/// (the frame the tile root and the camera both work in).
|
||||
fn probe_points() -> [Vec3; 3] {
|
||||
[
|
||||
benilla_assets::coords::wow_to_bevy([0.0, 0.0, 0.8]),
|
||||
benilla_assets::coords::wow_to_bevy([0.6, -0.3, 0.4]),
|
||||
benilla_assets::coords::wow_to_bevy([-0.4, 0.5, 1.1]),
|
||||
]
|
||||
}
|
||||
|
||||
/// Project the three points through whatever the renderer built this frame — the ACTIVE
|
||||
/// perspective camera and the tile root under its layer. `None` when no perspective camera is
|
||||
/// aimed (which is the orthographic leg, and the control's expected answer).
|
||||
fn read_leg(
|
||||
cams: &Query<
|
||||
(&Camera, &GlobalTransform, &Projection, &RenderLayers),
|
||||
With<TilePerspectiveCamera>,
|
||||
>,
|
||||
roots: &Query<(&GlobalTransform, &RenderLayers), With<TileRoot>>,
|
||||
) -> Option<Reading> {
|
||||
let (_, cam_tf, proj, layers) = cams.iter().find(|(c, _, _, _)| c.is_active)?;
|
||||
// The pane's root is the one on the camera's OWN layer — a perspective camera sees exactly one
|
||||
// tile by construction, and the interface has other tiles up (the minimap ping, the map arrow)
|
||||
// whose roots would otherwise be picked at random.
|
||||
let (root, _) = roots.iter().find(|(_, l)| *l == layers)?;
|
||||
let clip = proj.get_clip_from_view() * cam_tf.to_matrix().inverse() * root.to_matrix();
|
||||
let mut out = [Vec2::ZERO; 3];
|
||||
for (o, p) in out.iter_mut().zip(probe_points()) {
|
||||
let c = clip * p.extend(1.0);
|
||||
*o = Vec2::new(c.x / c.w, c.y / c.w);
|
||||
}
|
||||
Some(Reading(out))
|
||||
}
|
||||
|
||||
fn shoot(commands: &mut Commands, name: &str) {
|
||||
// Every file we write goes through `local_state` — never into the WoW install, never a
|
||||
// platform config dir (the contract's one-folder rule).
|
||||
let Some(path) = crate::local_state::home().map(|d| d.join(format!("{name}.png"))) else {
|
||||
warn!("PROBE_MODEL_CAMERA: no local-state folder — skipping the {name} shot");
|
||||
return;
|
||||
};
|
||||
info!("PROBE_MODEL_CAMERA: shot -> {}", path.display());
|
||||
commands
|
||||
.spawn(Screenshot::primary_window())
|
||||
.observe(save_to_disk(path));
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn model_camera_probe(
|
||||
mut commands: Commands,
|
||||
time: ProbeClock,
|
||||
mut probe: ResMut<ModelCameraProbe>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
tiles: Res<UiModelTiles>,
|
||||
self_player: Query<(), With<SelfPlayer>>,
|
||||
cams: Query<
|
||||
(&Camera, &GlobalTransform, &Projection, &RenderLayers),
|
||||
With<TilePerspectiveCamera>,
|
||||
>,
|
||||
roots: Query<(&GlobalTransform, &RenderLayers), With<TileRoot>>,
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
) {
|
||||
if self_player.is_empty() {
|
||||
return; // not in-world yet
|
||||
}
|
||||
let Some(script) = script else { return };
|
||||
let now = time.elapsed_secs_f64();
|
||||
|
||||
match probe.phase {
|
||||
Phase::Start => {
|
||||
info!("PROBE_MODEL_CAMERA: building a {PANE_W}x{PANE_H} <Model> on {FILE}, camera 0");
|
||||
if let Err(e) = script.run(BUILD) {
|
||||
error!("PROBE_MODEL_CAMERA: FAIL building the pane: {e}");
|
||||
probe.fails += 1;
|
||||
probe.phase = Phase::Done;
|
||||
return;
|
||||
}
|
||||
probe.phase = Phase::Await { since: now };
|
||||
}
|
||||
Phase::Await { since } => {
|
||||
if !tiles.cells.is_empty() && read_leg(&cams, &roots).is_some() {
|
||||
let cell = tiles.cells.values().next().copied();
|
||||
info!("PROBE_MODEL_CAMERA: tile up, cell {cell:?} — the perspective leg is live");
|
||||
probe.passes += 1;
|
||||
probe.phase = Phase::Settle {
|
||||
frames: 0,
|
||||
leg: Leg::Baseline,
|
||||
};
|
||||
} else if now - since > 30.0 {
|
||||
// The asset never landed, or the pane never reached the paint list. An
|
||||
// environmental miss, not a wrong answer.
|
||||
error!(
|
||||
"PROBE_MODEL_CAMERA: SKIP — no tile after 30 s (cells={}, camera aimed={})",
|
||||
tiles.cells.len(),
|
||||
read_leg(&cams, &roots).is_some()
|
||||
);
|
||||
probe.skips += 1;
|
||||
probe.phase = Phase::Done;
|
||||
}
|
||||
}
|
||||
Phase::Settle { frames, leg } => {
|
||||
if frames < SETTLE_FRAMES {
|
||||
probe.phase = Phase::Settle {
|
||||
frames: frames + 1,
|
||||
leg,
|
||||
};
|
||||
return;
|
||||
}
|
||||
let reading = read_leg(&cams, &roots);
|
||||
match (leg, reading) {
|
||||
(Leg::Baseline, Some(r)) => {
|
||||
info!("PROBE_MODEL_CAMERA: baseline ndc {:?}", r.0);
|
||||
probe.baseline = Some(r);
|
||||
shoot(&mut commands, "probe-model-camera-a-baseline");
|
||||
}
|
||||
(Leg::Ortho, None) => {
|
||||
info!(
|
||||
"PROBE_MODEL_CAMERA: ortho PASS — SetCamera(9) is past the file's two \
|
||||
cameras, so the NULL camera installed and the perspective camera parked"
|
||||
);
|
||||
probe.passes += 1;
|
||||
}
|
||||
(Leg::Ortho, Some(_)) => {
|
||||
error!(
|
||||
"PROBE_MODEL_CAMERA: ortho FAIL — an out-of-range SetCamera left the \
|
||||
perspective leg live; the bounds check is not being applied"
|
||||
);
|
||||
probe.fails += 1;
|
||||
}
|
||||
(_, None) => {
|
||||
error!("PROBE_MODEL_CAMERA: {leg:?} FAIL — the perspective camera went away");
|
||||
probe.fails += 1;
|
||||
}
|
||||
(_, Some(r)) => {
|
||||
let base = probe.baseline.unwrap_or_default();
|
||||
let worst = base
|
||||
.0
|
||||
.iter()
|
||||
.zip(r.0)
|
||||
.map(|(a, b)| (a.x - b.x).abs().max((a.y - b.y).abs()))
|
||||
.fold(0.0f32, f32::max);
|
||||
if worst <= NDC_EPS {
|
||||
info!(
|
||||
"PROBE_MODEL_CAMERA: {leg:?} PASS — cancels, worst NDC delta {worst:.6}"
|
||||
);
|
||||
probe.passes += 1;
|
||||
} else {
|
||||
error!(
|
||||
"PROBE_MODEL_CAMERA: {leg:?} FAIL — worst NDC delta {worst:.6} > \
|
||||
{NDC_EPS}; base {:?} got {:?}",
|
||||
base.0, r.0
|
||||
);
|
||||
probe.fails += 1;
|
||||
}
|
||||
if leg == Leg::Scale {
|
||||
shoot(&mut commands, "probe-model-camera-b-scale-and-position");
|
||||
}
|
||||
if leg == Leg::Facing {
|
||||
shoot(&mut commands, "probe-model-camera-c-facing");
|
||||
}
|
||||
}
|
||||
}
|
||||
match leg.next() {
|
||||
Some(next) => {
|
||||
if let Err(e) = script.run(next.setup()) {
|
||||
error!("PROBE_MODEL_CAMERA: FAIL setting up {next:?}: {e}");
|
||||
probe.fails += 1;
|
||||
probe.phase = Phase::Done;
|
||||
return;
|
||||
}
|
||||
probe.phase = Phase::Settle {
|
||||
frames: 0,
|
||||
leg: next,
|
||||
};
|
||||
}
|
||||
None => probe.phase = Phase::Done,
|
||||
}
|
||||
}
|
||||
Phase::Done => {
|
||||
if probe.exited {
|
||||
return;
|
||||
}
|
||||
probe.exited = true;
|
||||
info!(
|
||||
"PROBE_MODEL_CAMERA: DONE pass={} fail={} skip={}",
|
||||
probe.passes, probe.fails, probe.skips
|
||||
);
|
||||
exit.write(AppExit::Success);
|
||||
// The polite `AppExit` plus a hard backstop, the harness's standard pair
|
||||
// (`ProbeExitPlugin::fire_probe_exit`): a net or winit teardown hang must not leave a
|
||||
// zombie client holding the probe account.
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
warn!("PROBE_MODEL_CAMERA: still alive 5s after AppExit — hard exit");
|
||||
std::process::exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,7 @@ impl Plugin for DevProbesPlugin {
|
||||
"WOW_PROBE_CHEST",
|
||||
"WOW_PROBE_CLAM",
|
||||
"WOW_PROBE_VENDOR_SWAP",
|
||||
"WOW_PROBE_MODEL_CAMERA",
|
||||
"WOW_RIG",
|
||||
"WOW_LIVE_FPS",
|
||||
// A screenshot burst and a pick burst are wall-clock schedules too, and on an
|
||||
@@ -349,6 +350,13 @@ impl Plugin for DevProbesPlugin {
|
||||
if std::env::var("WOW_PROBE_VENDOR_SWAP").is_ok() {
|
||||
app.add_plugins(crate::capture::ProbeVendorSwapPlugin);
|
||||
}
|
||||
// The `<Model>` perspective-leg live probe: `WOW_PROBE_MODEL_CAMERA=1` builds a plain
|
||||
// pane on a camera-bearing file and reads the renderer's own camera and root back to
|
||||
// check the three cancellations against an orthographic control (decision 2027's
|
||||
// instrument; see `capture::ProbeModelCameraPlugin`, whose module doc is the recipe).
|
||||
if std::env::var("WOW_PROBE_MODEL_CAMERA").is_ok() {
|
||||
app.add_plugins(crate::capture::ProbeModelCameraPlugin);
|
||||
}
|
||||
// The GM trouble-ticket live probe: `WOW_PROBE_GMTICKET=1` drives the whole five-opcode
|
||||
// ticket wire through the live VM's own bindings — queue status, clean slate, file, edit,
|
||||
// abandon — and prints the row the server must have stored so the operator can check the
|
||||
|
||||
@@ -60,8 +60,9 @@ use benilla_world::lighting::{PropProbeSlot, PropProbes};
|
||||
use benilla_world::model_render::ShadeSel;
|
||||
use benilla_world::particles;
|
||||
use benilla_world::terrain_stream::{
|
||||
build_collider_task, fold_interior_probe, m2_anim_bound, m2_fade, placement_collider_data,
|
||||
point_light, spawn_model_entities, PendingCollider, PropLobeLight, SpawnedModel,
|
||||
build_collider_task, fold_interior_probe, hex_word, m2_anim_bound, m2_fade,
|
||||
placement_collider_data, point_light, spawn_model_entities, PendingCollider, PropLobeLight,
|
||||
SpawnedModel,
|
||||
};
|
||||
|
||||
use super::{GameObjects, ModelHandle, VisualAttached};
|
||||
@@ -271,7 +272,23 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
.map(|p| p.path().to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
id: 0,
|
||||
detail: "WMO gameobject prop".into(),
|
||||
// The prop's LANE, the way a terrain-placed prop's identity names it
|
||||
// (`PropLight::inspector_label`): a prop that looks wrong is almost always on the
|
||||
// wrong lane or reading the wrong probe, and neither is visible from the model
|
||||
// path. This lane's props were the ones the entity shade writer renamed out from
|
||||
// under (B373), and a hover said nothing at all.
|
||||
detail: match (&prop.interior, interior_slot) {
|
||||
(Some(lane), Some(slot)) => format!(
|
||||
"WMO gameobject prop · interior amb {} dif {} · {} MOLR · probe slot {slot}",
|
||||
hex_word(lane.ambient),
|
||||
hex_word(lane.diffuse),
|
||||
lane.lights.len(),
|
||||
),
|
||||
(Some(_), None) => {
|
||||
"WMO gameobject prop · interior, NO PROBE SLOT (table full — sky-lit)".into()
|
||||
}
|
||||
(None, _) => "WMO gameobject prop · sky-lit".into(),
|
||||
},
|
||||
});
|
||||
let SpawnedModel {
|
||||
entities: ents,
|
||||
|
||||
@@ -1071,6 +1071,11 @@ impl Plugin for MinimapPlugin {
|
||||
probe_minimap_widget
|
||||
.in_set(UiQuadAppend)
|
||||
.before(emit_minimap),
|
||||
// Deliberately NOT on the lighting resolve's read side, though it reads
|
||||
// `WowLighting` (decision 2032): joining `LightingConsumeSet` would put a
|
||||
// 178th engine item through the world API wall, and what it buys is one
|
||||
// frame of the right day-night tint on a 140 px map — invisible even on a
|
||||
// submersion crossing, which is the one moment that value jumps.
|
||||
emit_minimap.in_set(UiQuadAppend),
|
||||
// After the emit that fills it: the composite camera draws what THIS frame's
|
||||
// interior branch asked for, so the target the blit quad samples is never a
|
||||
|
||||
@@ -255,8 +255,10 @@ pub(super) fn disconnected(
|
||||
*duel = crate::ui_duel::DuelState::default();
|
||||
// The friend/ignore lists and the last `/who` are session state too (decision 0668): the
|
||||
// server re-pushes both lists at the next login, and a stale ignore list would silence the
|
||||
// wrong guids after a reconnect renumbers nothing but re-streams everything.
|
||||
*social = crate::ui_social::SocialState::default();
|
||||
// wrong guids after a reconnect renumbers nothing but re-streams everything. The `/who` sort
|
||||
// chain is the one thing that survives — it is per-PROCESS in the reference, not per-login
|
||||
// (decision 2030), which is why this is a `clear_session` and not a `default()`.
|
||||
social.clear_session();
|
||||
// The guild session is login-scoped the same way (decision 1257) — and more strictly, because
|
||||
// the next login may be a *different character*, whose guild id, rank, rights and roster share
|
||||
// nothing with this one's. The identity cache goes too: it is keyed by guild id, so it would
|
||||
|
||||
@@ -199,7 +199,7 @@ pub(super) fn glue_zoom_floor(fov: f32) -> f32 {
|
||||
/// write — it would stomp ours. `update` is a deliberate no-op, and that is faithful: the client's
|
||||
/// bake emits the *same* matrix at 1152×648 and at 1280×800 (1543).
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct WowPortraitProjection {
|
||||
pub(crate) struct WowPortraitProjection {
|
||||
/// The M2 record's fov (radians) — a *diagonal* angle in the client's convention, NOT fovy.
|
||||
pub(super) fov: f32,
|
||||
pub(super) near: f32,
|
||||
@@ -525,7 +525,7 @@ pub(super) fn pane_camera(a: &PortraitAnchors) -> benilla_assets::PortraitCamera
|
||||
/// The falsifier, if anyone is tempted to propagate the portrait's `1.0` here anyway: the viewport
|
||||
/// comes from that same rect, so projection and viewport aspects agree by construction, and a
|
||||
/// transplanted `1.0` would render a sphere **1.42× wider than tall** in the 318×224 pet pane.
|
||||
pub(super) fn pane_projection(
|
||||
pub(crate) fn pane_projection(
|
||||
cam: &benilla_assets::PortraitCamera,
|
||||
aspect: f32,
|
||||
) -> WowPortraitProjection {
|
||||
|
||||
@@ -32,7 +32,7 @@ use super::framing::{
|
||||
use super::{
|
||||
aim, body_frame, new_target_image, spawn_booth_effects, spawn_booth_model, Booth,
|
||||
BoothBillboardSpec, BoothCam, BoothEffects, BoothInstance, BoothLight, BoothMotion, BoothPart,
|
||||
BoothRider, BoothTwins, Booths, PortraitImages, PortraitSource, GLUE_LAYER,
|
||||
BoothRider, BoothTwins, Booths, PortraitImages, PortraitSource, VariantLane, GLUE_LAYER,
|
||||
};
|
||||
|
||||
/// The glue booth slot token (its key in [`super::PortraitImages`] / [`Booths`]).
|
||||
@@ -1378,10 +1378,14 @@ pub(super) fn sync_glue_booth(
|
||||
// The glue screens keep the old fallback deliberately: the create/select scene has
|
||||
// its own lifecycle (no map-scope teardown under it) and no parts-key retry to
|
||||
// ride, so waiting here would leave the pane empty instead of merely mislit.
|
||||
(Some(buf), Some(s)) => {
|
||||
super::material_variant(&mut s.variants, buf, material, materials, true)
|
||||
.unwrap_or_else(|| material.clone())
|
||||
}
|
||||
(Some(buf), Some(s)) => super::material_variant(
|
||||
&mut s.variants,
|
||||
buf,
|
||||
material,
|
||||
materials,
|
||||
VariantLane::RigUnfogged,
|
||||
)
|
||||
.unwrap_or_else(|| material.clone()),
|
||||
_ => booth_light.studio.variant(material, materials),
|
||||
}
|
||||
};
|
||||
@@ -1652,8 +1656,14 @@ pub(super) fn sync_glue_pet(
|
||||
AssetId<WowModelMaterial>,
|
||||
Handle<WowModelMaterial>,
|
||||
>| {
|
||||
super::material_variant(variants, &light, material, &mut materials, true)
|
||||
.unwrap_or_else(|| material.clone())
|
||||
super::material_variant(
|
||||
variants,
|
||||
&light,
|
||||
material,
|
||||
&mut materials,
|
||||
VariantLane::RigUnfogged,
|
||||
)
|
||||
.unwrap_or_else(|| material.clone())
|
||||
};
|
||||
let booth_parts: Vec<BoothPart> = pet
|
||||
.parts
|
||||
|
||||
@@ -53,7 +53,13 @@ impl BoothRig {
|
||||
}
|
||||
return world.clone();
|
||||
};
|
||||
match material_variant(&mut self.variants, &buffer, world, materials, false) {
|
||||
match material_variant(
|
||||
&mut self.variants,
|
||||
&buffer,
|
||||
world,
|
||||
materials,
|
||||
VariantLane::World,
|
||||
) {
|
||||
Some(twin) => twin,
|
||||
None => {
|
||||
self.unready = true;
|
||||
@@ -106,16 +112,30 @@ pub(super) fn reap_dead_variants(
|
||||
}
|
||||
}
|
||||
|
||||
/// Which lane a [`material_variant`] twin draws on — the two axes the twin can differ from its
|
||||
/// world source on, as one closed choice rather than a bool nobody can read at the call site.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum VariantLane {
|
||||
/// The world's own shade lane, the batch's authored fog policy kept — the portrait booths,
|
||||
/// which render a model exactly as the world would but under a frozen light.
|
||||
World,
|
||||
/// The scene's authored M2 light rig ([`benilla_world::model_render::ShadeSel::Rig`] — the
|
||||
/// probe-slot SH eval + the buffer's point table, decisions 0429/0435) **with fog forced
|
||||
/// OFF**: the glue CHARACTER model takes no fog in the reference (its fill callback stages
|
||||
/// none, its collector's fog stays zeroed — wow-re `glue-model-lighting.md` §5), and so does
|
||||
/// a `<Model>` pane that never armed any.
|
||||
RigUnfogged,
|
||||
/// The rig lane with the batch's **authored** fog policy kept — a `<Model>` pane whose Lua
|
||||
/// armed fog (decision 2027). The per-material UNFOGGED bit (`0x02`) then does its own work,
|
||||
/// which is the reference's own per-batch fork: a fogged pane still draws its UNFOGGED
|
||||
/// materials unfogged (render law §5.6). The glue background scene takes this lane too.
|
||||
RigFogged,
|
||||
}
|
||||
|
||||
/// The twin of a world-built material against `buffer`, cached in `variants` — same
|
||||
/// texture/blend/flags, only the light storage swapped. [`BoothRig::variant`] is this against one of
|
||||
/// the two fixed booth buffers; the create scene passes its own authored-rig buffer with `rig` set,
|
||||
/// which additionally flips the twin onto the [`benilla_world::model_render::ShadeSel::Rig`] lane (the
|
||||
/// probe-slot SH eval + the buffer's point table — the scene's authored M2 light rig, decision
|
||||
/// 0429/0435) instead of the world sun/intensity lane the material was built for — and forces the
|
||||
/// twin's fog OFF: the glue CHARACTER model takes no fog in the reference (its fill callback
|
||||
/// stages none, its collector fog stays zeroed — wow-re `glue-model-lighting.md §5`; the
|
||||
/// background scene model, built by `sync_create_scene` with its own fog policy, keeps the
|
||||
/// `CharModelFogInfo` fog).
|
||||
/// texture/blend/flags, only the light storage swapped, plus whatever [`VariantLane`] asks for.
|
||||
/// [`BoothRig::variant`] is this on [`VariantLane::World`] against one of the two fixed booth
|
||||
/// buffers; the create scene and the UI model tiles pass their own buffers on a rig lane.
|
||||
///
|
||||
/// **`None` means the source material is not resident yet** — there is no twin to hand back, and
|
||||
/// the world material is NOT an acceptable substitute in a booth (wrong light buffer; see
|
||||
@@ -125,7 +145,7 @@ pub(crate) fn material_variant(
|
||||
buffer: &bevy::render::render_resource::Buffer,
|
||||
world: &Handle<WowModelMaterial>,
|
||||
materials: &mut Assets<WowModelMaterial>,
|
||||
rig: bool,
|
||||
lane: VariantLane,
|
||||
) -> Option<Handle<WowModelMaterial>> {
|
||||
if let Some(twin) = variants.get(&world.id()) {
|
||||
return Some(twin.clone());
|
||||
@@ -136,8 +156,10 @@ pub(crate) fn material_variant(
|
||||
let mat = materials.get(world)?;
|
||||
let mut twin = mat.clone();
|
||||
twin.extension.light_buf = buffer.clone();
|
||||
if rig {
|
||||
if lane != VariantLane::World {
|
||||
twin.extension.sun_scale.x = benilla_world::model_render::ShadeSel::Rig.selector();
|
||||
}
|
||||
if lane == VariantLane::RigUnfogged {
|
||||
// Force fog OFF while preserving every pipeline marker `specialize` keys on (bits 0-3
|
||||
// AND the 0528 multiply markers, bits 7-8) — the mask is owned by `model_render`, next
|
||||
// to the packer. A hand-rolled `as u8 & 0x0f` here once dropped the multiply markers
|
||||
|
||||
@@ -78,7 +78,9 @@ use crate::target::Selection;
|
||||
use benilla_assets::materials::WowModelMaterial;
|
||||
|
||||
mod framing;
|
||||
pub(crate) use framing::{attachment_point, head_anchor, PortraitAnchors};
|
||||
pub(crate) use framing::{
|
||||
attachment_point, head_anchor, pane_projection, PortraitAnchors, WowPortraitProjection,
|
||||
};
|
||||
use framing::{body_frame, frame, PORTRAIT_FOV};
|
||||
mod booth;
|
||||
/// The translucency twins ride the *preview* shapes as well as the booth ones — one type for both
|
||||
@@ -97,7 +99,7 @@ pub(crate) use glue_booth::{
|
||||
PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SelectLook, GLUE_SLOT,
|
||||
};
|
||||
mod light;
|
||||
pub(crate) use light::material_variant;
|
||||
pub(crate) use light::{material_variant, VariantLane};
|
||||
use light::{model_pane_light, studio_light, BoothLight};
|
||||
mod test_bake;
|
||||
|
||||
@@ -230,6 +232,19 @@ pub(crate) const MINIMAP_COMPOSITE_LAYER: usize = WARM_BOOTH_LAYER + 1;
|
||||
/// The UI model tiles' layer (`crate::ui_models`, decision 2008): every `<Model>` widget's M2
|
||||
/// renders into one atlas through one camera on this layer.
|
||||
pub(crate) const UI_MODELS_LAYER: usize = MINIMAP_COMPOSITE_LAYER + 1;
|
||||
/// The base of the **perspective model panes'** layer block (decision 2027). A `<Model>` framed
|
||||
/// by its file's own camera cannot share the tile atlas's one orthographic camera — it needs a
|
||||
/// camera of its own, rendering into its own cell of the same atlas through a viewport — and one
|
||||
/// camera per pane means one layer per pane, or every perspective camera would draw every other
|
||||
/// pane's model over its cell. The block runs `BASE + i` for `i < UI_MODEL_CAM_LAYERS` and sits at
|
||||
/// the TOP of the ladder, so it can be widened without colliding with anything above it.
|
||||
pub(crate) const UI_MODEL_CAM_LAYER_BASE: usize = UI_MODELS_LAYER + 1;
|
||||
/// How many perspective model panes can draw at once — the size of the layer block above and of
|
||||
/// the camera pool beside it. Each is a full render pass into the atlas, and a UI showing eight
|
||||
/// authored-camera 3-D scenes at once is already far past anything the reference's interface or
|
||||
/// the addon corpus does; a ninth pane holds its cell and draws nothing, the same degrade the
|
||||
/// atlas already makes for a tile that does not fit.
|
||||
pub(crate) const UI_MODEL_CAM_LAYERS: usize = 8;
|
||||
|
||||
// The ladder must stay collision-free: a booth camera's layer is its identity for both rendering
|
||||
// and the emitter→camera match, and the failure above was silent in both.
|
||||
@@ -247,7 +262,8 @@ const _: () = assert!(
|
||||
&& DRESSUP_LAYER > GLUE_LAYER
|
||||
&& WARM_BOOTH_LAYER > DRESSUP_LAYER
|
||||
&& MINIMAP_COMPOSITE_LAYER > WARM_BOOTH_LAYER
|
||||
&& UI_MODELS_LAYER > MINIMAP_COMPOSITE_LAYER,
|
||||
&& UI_MODELS_LAYER > MINIMAP_COMPOSITE_LAYER
|
||||
&& UI_MODEL_CAM_LAYER_BASE > UI_MODELS_LAYER,
|
||||
"booth render layers must be distinct — see GLUE_LAYER"
|
||||
);
|
||||
const _: () = assert!(
|
||||
|
||||
@@ -205,15 +205,23 @@ impl Transport {
|
||||
}
|
||||
|
||||
/// Re-anchor this transport's clock so its cycle sits at the head of `map_id`'s leg, and
|
||||
/// return the sample there. `None` if the drive has no leg on that map (a lift never does).
|
||||
/// return the sample there **plus the cycle instant it was moved to, if it moved at all**.
|
||||
/// `None` if the drive has no leg on that map (a lift never does).
|
||||
///
|
||||
/// The anchor is stored as `progress_ms` + the instant it was taken, so seeking is simply
|
||||
/// "restamp both": the clock runs on from the new cycle position exactly as it would have.
|
||||
///
|
||||
/// **The second element must be how the caller decides whether anything happened.** The stored
|
||||
/// `progress_ms` is the server's own uptime-scale path progress until a seek replaces it —
|
||||
/// unbounded, and nothing like a cycle position — so "did it change?" cannot be answered by
|
||||
/// comparing it against a `% period` cycle value. Doing exactly that made the healthy
|
||||
/// already-agreed crossing print `our path clock was 157334 ms behind` with a target of
|
||||
/// 35081741 ms, which is 9.7 hours: the server's uptime, not a position in a 350 s loop.
|
||||
pub(crate) fn reseek_to_map(
|
||||
&self,
|
||||
anchor: &mut TransportAnchor,
|
||||
map_id: u32,
|
||||
) -> Option<TransportSample> {
|
||||
) -> Option<(TransportSample, Option<u32>)> {
|
||||
let Drive::Taxi(t) = &self.drive else {
|
||||
return None;
|
||||
};
|
||||
@@ -224,12 +232,12 @@ impl Transport {
|
||||
// Only a clock that actually disagrees with the server gets corrected.
|
||||
let here = t.sample(cycle);
|
||||
if here.map == map_id {
|
||||
return Some(here);
|
||||
return Some((here, None));
|
||||
}
|
||||
let target = t.first_cycle_on_map(cycle, map_id)?;
|
||||
anchor.progress_ms = target;
|
||||
anchor.at = Instant::now();
|
||||
Some(t.sample(target))
|
||||
Some((t.sample(target), Some(target)))
|
||||
}
|
||||
|
||||
/// Whether any leg of this transport's cycle lies on `map_id` — the cross-map worldport's
|
||||
@@ -598,7 +606,7 @@ fn reseek_ridden_transport_at_worldport(
|
||||
continue;
|
||||
};
|
||||
let before = transport.cycle_ms(&anchor);
|
||||
let Some(sample) = transport.reseek_to_map(&mut anchor, w.map_id) else {
|
||||
let Some((sample, moved_to)) = transport.reseek_to_map(&mut anchor, w.map_id) else {
|
||||
warn!(
|
||||
"transport {:#x}: rode a transfer to map {} its own path never visits — cannot \
|
||||
re-anchor; the rider's pose will be composed through a stale one",
|
||||
@@ -606,17 +614,16 @@ fn reseek_ridden_transport_at_worldport(
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let after = anchor.progress_ms;
|
||||
// Silent when we were already there (the healthy crossing — the two clocks agreed and the
|
||||
// seek was a no-op). Loud when it moved, carrying **how far behind the server our own
|
||||
// path clock was**: this is the only measurement of client-vs-server transport drift the
|
||||
// client can take, and it is worth a line every time it is not zero.
|
||||
// seek was a no-op, which `moved_to == None` is the ONLY sound way to detect). Loud when it
|
||||
// moved, carrying **how far behind the server our own path clock was**: this is the only
|
||||
// measurement of client-vs-server transport drift the client can take.
|
||||
//
|
||||
// Measured **forward around the cycle**, because a crossing is very often the cycle wrap
|
||||
// (on taxi path 241 the Kalimdor legs are the *first* frames, so "cross to Kalimdor" IS
|
||||
// the wrap): a plain `after - before` reports a 144 ms nudge as −356 140 ms and reads as
|
||||
// the clock running half a cycle ahead, which is the opposite of what happened.
|
||||
if before != after {
|
||||
if let Some(after) = moved_to {
|
||||
let period = transport.period_ms().max(1);
|
||||
let behind =
|
||||
(u64::from(after) + u64::from(period) - u64::from(before)) % u64::from(period);
|
||||
@@ -934,10 +941,14 @@ mod tests {
|
||||
progress_ms: on_azeroth,
|
||||
at: Instant::now(),
|
||||
};
|
||||
let sample = transport
|
||||
let (sample, moved_to) = transport
|
||||
.reseek_to_map(&mut anchor, 1)
|
||||
.expect("path 241 visits Kalimdor");
|
||||
assert_eq!(sample.map, 1, "the re-anchored sample must be on Kalimdor");
|
||||
assert!(
|
||||
moved_to.is_some(),
|
||||
"a clock on Azeroth's leg told it is on Kalimdor must report that it moved"
|
||||
);
|
||||
assert_eq!(
|
||||
transport.sample(anchor.progress_ms, 0).map,
|
||||
1,
|
||||
@@ -951,7 +962,7 @@ mod tests {
|
||||
progress_ms: on_kalimdor,
|
||||
at: Instant::now(),
|
||||
};
|
||||
let sample = transport
|
||||
let (sample, moved_to) = transport
|
||||
.reseek_to_map(&mut anchor, 1)
|
||||
.expect("path 241 visits Kalimdor");
|
||||
assert_eq!(sample.map, 1);
|
||||
@@ -959,6 +970,14 @@ mod tests {
|
||||
anchor.progress_ms, on_kalimdor,
|
||||
"an agreeing clock must not be touched"
|
||||
);
|
||||
// **And it must SAY it did nothing.** The stored anchor is the server's uptime-scale path
|
||||
// progress until a seek replaces it, so a caller cannot infer "unchanged" by comparing it
|
||||
// against a cycle value — it reported a 9.7-hour raw progress as a 157 s clock skew until
|
||||
// this flag existed.
|
||||
assert_eq!(
|
||||
moved_to, None,
|
||||
"a no-op seek must report that it did not move the clock"
|
||||
);
|
||||
|
||||
// A map the path never visits has no answer — the caller warns rather than inventing one.
|
||||
let mut anchor = TransportAnchor {
|
||||
|
||||
@@ -587,7 +587,17 @@ pub(super) fn plugin(app: &mut App) {
|
||||
)
|
||||
.add_systems(
|
||||
OnExit(crate::char_select::ClientState::InWorld),
|
||||
save_on_session_end,
|
||||
// **Explicitly before the session ender, because the edge is not an ordering.** This
|
||||
// flush composes the file out of the DYING VM, and `end_ui_session` replaces that VM
|
||||
// with a fresh boot one on the same edge. Two unconstrained systems there are placed
|
||||
// by the executor, and that placement was measured (bevy 0.18, five systems on one
|
||||
// `OnExit`) to move with nothing but their registration positions — a VM-reading
|
||||
// saver ran after the exclusive ender in one arrangement and before it in another.
|
||||
// Winning that race by luck is what B353 cost the layout cache one module over; the
|
||||
// layout cache answered it by joining the reference's shutdown tail, which this file
|
||||
// cannot do (its reference is the type-7 chat-cache saver `0x499a80`, not
|
||||
// `0x490bd0`'s tail), so it says the order instead.
|
||||
save_on_session_end.before(crate::ui_script::end_ui_session),
|
||||
);
|
||||
// The quit flush rides the exit edge rather than `Update` for decision 1528's reason: the
|
||||
// close button's `AppExit` is not written until `PostUpdate`, so a save chained beside the
|
||||
|
||||
@@ -40,16 +40,42 @@
|
||||
//! docked windows share one level. It joins the file the day a window can be raised for good —
|
||||
//! the honest-tree rule (1134 §4), the same one the chat-look file applies next door.
|
||||
//!
|
||||
//! ## The write posture — [`crate::ui_chat`]'s `settings`, verbatim
|
||||
//! ## The write posture — the reference's shutdown tail, plus a debounce it has not got
|
||||
//!
|
||||
//! Dirty flag keyed to the VM ([`VmMemo`]), one quiet second, plus both session edges
|
||||
//! (`OnExit(InWorld)` and `AppExit`). The VM key is the load-bearing part and it is one-way: the
|
||||
//! geometry lives in the VM, so a plain `bool` surviving a VM replacement would let a save compose
|
||||
//! the player's file out of a fresh tree that has no user-placed frame in it at all — i.e. wipe it.
|
||||
//! A fresh VM starts undirty and cannot write until a drag writes.
|
||||
//! **The reference writes this file in exactly one place**: step three of the UI shutdown's
|
||||
//! ordered tail `0x490bd0` — `PLAYER_LEAVING_WORLD` → `PLAYER_LOGOUT` (`0x490c2a`) →
|
||||
//! **`layout-cache.txt` (`0x490c79`)** → the flat saved file (`0x490c7e`) → the per-addon files →
|
||||
//! `AddOns.txt` → the frame teardown. It rides the same five lifecycle roots as SavedVariables —
|
||||
//! logout to the character screen, quit, disconnect, application exit and **`/reload`** — and
|
||||
//! there is no autosave, no dirty bit and no per-frame write (wow-5875-re `system/ui/ui.md`;
|
||||
//! `scratch/camera-settings-persistence.md` §4).
|
||||
//!
|
||||
//! A drag is a slider-shaped gesture, so the debounce matters for the same reason it does there:
|
||||
//! a resize writes on every mouse-move, and the quiet second coalesces the lot into one file write.
|
||||
//! Ours takes that slot: [`save_now`], called from [`crate::ui_script::shutdown_ui_state`]
|
||||
//! between `PLAYER_LOGOUT` and the flat file. **That is the load-bearing part, and it is what
|
||||
//! B353 was.** The tail is ONE function called from every root, which is the only way a
|
||||
//! `/reload` gets a write at all: a reload never leaves `InWorld` — `run_pending_reload` calls
|
||||
//! the shutdown and the rebuild back to back — so a saver hung off `OnExit(InWorld)` is invisible
|
||||
//! to it, and an unlocked chat window came back on its authored anchors after every `/reload`
|
||||
//! with `benilla-config/layout/` never created. The same edge is *also* not an ordering: two
|
||||
//! unconstrained systems on one state edge are placed by the executor, and measured here (bevy
|
||||
//! 0.18, five systems on one `OnExit`) the placement moves with nothing but the registration
|
||||
//! positions — a saver that needs the dying VM ran *after* the exclusive session-ender in one
|
||||
//! arrangement and before it in another. So this module's `OnExit` saver was reading a VM it was
|
||||
//! racing for. The tail cannot race: it runs inside `end_ui_session`, ahead of the replacement,
|
||||
//! by construction. That is the mistake [`crate::ui_script::shutdown_ui_state`]'s own doc says
|
||||
//! the tail exists to prevent, made once more one module over.
|
||||
//!
|
||||
//! **The one thing we keep that the reference has not got is a debounced autosave** —
|
||||
//! [`save_layout`], dirty flag keyed to the VM ([`VmMemo`]), one quiet second. The reference has
|
||||
//! no crash-path write either (its exception filter reaches no writer) and we would rather not
|
||||
//! lose a session's windows to one; a drag is a slider-shaped gesture, so the quiet second
|
||||
//! coalesces a resize's per-mouse-move writes into one. It is a *second* writer of the same
|
||||
//! file, never the only one: every orderly end goes through the tail.
|
||||
//!
|
||||
//! The VM key on the dirty flag is one-way and load-bearing: the geometry lives in the VM, so a
|
||||
//! plain `bool` surviving a VM replacement would let a save compose the player's file out of a
|
||||
//! fresh tree that has no user-placed frame in it at all — i.e. wipe it. A fresh VM starts
|
||||
//! undirty and cannot write until a drag writes.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -179,11 +205,15 @@ pub(crate) fn parse(text: &str) -> Vec<FrameLayout> {
|
||||
|
||||
/// Seat the player's saved geometry into the VM, once per character per VM.
|
||||
///
|
||||
/// `pub(crate)` so `ui_script::world_entry_tests` can run it against a real post-reload VM:
|
||||
/// the file half of the loop is only worth anything if a window actually comes BACK, and
|
||||
/// that is the half a test of `render`/`parse` alone cannot see.
|
||||
///
|
||||
/// Runs in `Update` under `InWorld`, which is *after* the UI tree is built and after decision
|
||||
/// 0272's load-time `UIParent_ManageFramePositions()` bootstrap — both of which matter: the frames
|
||||
/// have to exist to be looked up by name, and the managed pass has to have had its say first,
|
||||
/// because from here on it skips these frames (`IsUserPlaced`, `UIParent.xml`).
|
||||
fn load_layout(
|
||||
pub(crate) fn load_layout(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
roster: Res<crate::char_select::Roster>,
|
||||
mut file: ResMut<LayoutFile>,
|
||||
@@ -239,18 +269,15 @@ fn watch_layout(script: Option<NonSendMut<UiScript>>, mut file: ResMut<LayoutFil
|
||||
file.last_change = Some(std::time::Instant::now());
|
||||
}
|
||||
|
||||
/// Dirty + one quiet second (or the app exiting) → rewrite the file atomically.
|
||||
fn save_layout(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut file: ResMut<LayoutFile>,
|
||||
mut exits: MessageReader<AppExit>,
|
||||
) {
|
||||
let exiting = exits.read().next().is_some();
|
||||
/// Dirty + one quiet second → rewrite the file atomically. **The crash writer, and only that**
|
||||
/// (see the module doc): every orderly end — logout, disconnect, quit, `/reload` — is the
|
||||
/// shutdown tail's, so this never has to reason about an exit message or an edge.
|
||||
fn save_layout(script: Option<NonSendMut<UiScript>>, mut file: ResMut<LayoutFile>) {
|
||||
let Some(script) = script else { return };
|
||||
if !*file.dirty.get(&script) {
|
||||
return;
|
||||
}
|
||||
if !(exiting || file.last_change.is_none_or(|t| t.elapsed() >= SAVE_QUIET)) {
|
||||
if !file.last_change.is_none_or(|t| t.elapsed() >= SAVE_QUIET) {
|
||||
return;
|
||||
}
|
||||
let Some(path) = file.path.clone() else {
|
||||
@@ -266,42 +293,54 @@ fn save_layout(
|
||||
*file.dirty.get(&script) = false;
|
||||
}
|
||||
|
||||
/// `OnExit(InWorld)` — a `/logout` back to the glue, or a disconnect. The same edge the camera pose,
|
||||
/// the chat look and the saved variables flush on, and it must not wait for the quiet second.
|
||||
fn save_on_session_end(script: Option<NonSendMut<UiScript>>, mut file: ResMut<LayoutFile>) {
|
||||
let Some(script) = script else { return };
|
||||
if !*file.dirty.get(&script) {
|
||||
return;
|
||||
/// **The reference's own step of the UI shutdown** (`0x490c79`, between `PLAYER_LOGOUT` and the
|
||||
/// flat saved file): write the player's layout cache from the live VM, now. Called from
|
||||
/// [`crate::ui_script::shutdown_ui_state`], so it reaches every root the tail does — logout,
|
||||
/// disconnect, quit and `/reload` — which is what an `OnExit(InWorld)` system could not (module
|
||||
/// doc, and B353).
|
||||
///
|
||||
/// It takes the **identity the tail is holding** rather than reading [`LayoutFile`], for the
|
||||
/// reason [`crate::ui_script::AddOnIdentity`] exists at all: that is the character the UI
|
||||
/// actually loaded under, remembered because the roster's pick can be gone by the time the
|
||||
/// shutdown runs. It is the same `(realm, character)` [`load_layout`] built its path from —
|
||||
/// both are [`crate::ui_macro::identity`] of the same roster — so the tail writes back to the
|
||||
/// file the load read.
|
||||
///
|
||||
/// Unconditional, like every other resident of the tail: the file is composed whole from the
|
||||
/// live tree, so a session that placed nothing writes back what it restored, and a session that
|
||||
/// un-placed a window writes the row's absence. The one guard is the tail's own — a session
|
||||
/// whose in-game UI never loaded does not run it, so a UI-less VM cannot answer with its
|
||||
/// emptiness.
|
||||
pub(crate) fn save_now(script: &UiScript, identity: Option<&(String, String)>) {
|
||||
let Some((realm, character)) = identity else {
|
||||
return; // no character loaded under — a capture, a scenario, a test world
|
||||
};
|
||||
let Some(path) = crate::local_state::layout_character_path(realm, character) else {
|
||||
return; // hermetic capture, or no state folder — session-only
|
||||
};
|
||||
let body = render(&script.user_placed_layouts());
|
||||
if let Err(e) = crate::local_state::write_atomic(&path, &body) {
|
||||
warn!("layout: cannot write {}: {e}", path.display());
|
||||
}
|
||||
if let Some(path) = file.path.clone() {
|
||||
let body = render(&script.user_placed_layouts());
|
||||
if let Err(e) = crate::local_state::write_atomic(&path, &body) {
|
||||
warn!("layout: cannot write {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
*file.dirty.get(&script) = false;
|
||||
}
|
||||
|
||||
/// The layout cache's plugin — the chat look's shape one store over.
|
||||
/// The layout cache's plugin. **Not the chat look's shape any more** (2029): that module keeps
|
||||
/// its own session edge because its reference is the type-7 chat-cache saver `0x499a80`;
|
||||
/// this file's reference is `0x490bd0`'s ordered tail, so its every orderly write lives in
|
||||
/// [`save_now`] and the plugin has no edge left to own.
|
||||
pub(crate) struct UiLayoutPlugin;
|
||||
|
||||
impl Plugin for UiLayoutPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<LayoutFile>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(load_layout, watch_layout)
|
||||
.chain()
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
)
|
||||
.add_systems(
|
||||
OnExit(crate::char_select::ClientState::InWorld),
|
||||
save_on_session_end,
|
||||
);
|
||||
// The quit flush rides the exit edge rather than `Update` for decision 1528's reason: the
|
||||
// close button's `AppExit` is not written until `PostUpdate`, so a save chained beside the
|
||||
// watcher would lose the last second of a drag to the process ending.
|
||||
crate::shutdown::on_app_exit(app, save_layout.into_configs());
|
||||
// **One chain, and no session edges of its own.** Restore, watch, and the crash-only
|
||||
// debounce; every orderly end is [`crate::ui_script::shutdown_ui_state`]'s [`save_now`],
|
||||
// which is the reference's own single write site and the only one a `/reload` reaches.
|
||||
app.init_resource::<LayoutFile>().add_systems(
|
||||
Update,
|
||||
(load_layout, watch_layout, save_layout)
|
||||
.chain()
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1878,6 +1878,7 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
|
||||
looping: false,
|
||||
}],
|
||||
bbox: ([0.0, 0.0, 0.0], [0.02707, 0.07962, 0.0]),
|
||||
cameras: 0,
|
||||
},
|
||||
);
|
||||
s.resolve();
|
||||
|
||||
@@ -485,6 +485,7 @@ fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference
|
||||
looping: false,
|
||||
}],
|
||||
bbox: ([0.0, 0.0, 0.0], [0.02707, 0.07962, 0.0]),
|
||||
cameras: 0,
|
||||
},
|
||||
);
|
||||
s.run("MultiBarBottomLeft:Show() MultiBarBottomRight:Show() UIParent_ManageFramePositions()")
|
||||
|
||||
@@ -486,6 +486,27 @@ fn quad_summary(q: Option<&UiQuad>) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// **Hand the VM the host's font engine** — the one construction site for
|
||||
/// [`crate::ui_text::AtlasMeasurer`], so a `SetText` → `GetStringWidth` pair *inside one Lua call*
|
||||
/// returns a real number instead of 0 (the reference answers that getter inline, `0x79e510` →
|
||||
/// `0x772890`).
|
||||
///
|
||||
/// `seam` is the raster seam the atlas answers under ([`super::seam_scale`]): a measurer built
|
||||
/// under one seam does not answer for another, which is why both callers re-seat rather than
|
||||
/// check first.
|
||||
///
|
||||
/// Two callers, and the second one is the point (decision 2028): this pass, at the seam edge and
|
||||
/// on the first frame the atlas exists — and [`super::lifecycle::load_ingame_ui_on_world_entry`],
|
||||
/// **before the manifest loads**, because that is where every `<OnLoad>` in the in-game UI runs.
|
||||
/// Seated only from here, a VM born and loaded inside one exclusive system — which is exactly
|
||||
/// what `ReloadUI()` does — runs its whole load edge measuring 0.
|
||||
pub(crate) fn seat_text_measurer(script: &mut UiScript, atlas: &UiFontAtlas, seam: f32) {
|
||||
script.set_text_measurer(Box::new(crate::ui_text::AtlasMeasurer::new(
|
||||
atlas.engine(),
|
||||
seam,
|
||||
)));
|
||||
}
|
||||
|
||||
/// Per frame: screen size → `tick` (OnUpdate) → `resolve` → `extract` → [`UiQuads`]. Script errors
|
||||
/// drain to the log (throttled by being drained — each fires once).
|
||||
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
|
||||
@@ -621,13 +642,11 @@ pub(super) fn drive_script(
|
||||
//
|
||||
// Seated BEFORE the tick below, so the first update that runs already has it, and rebuilt only
|
||||
// on the seam edge or the frame the atlas first exists — an `Arc` clone and an `f32`, never a
|
||||
// per-frame cost.
|
||||
// per-frame cost. This is the *frame* edge; the *load* edge is
|
||||
// [`super::lifecycle::load_ingame_ui_on_world_entry`]'s own call to the same seat (2028).
|
||||
if let Some(atlas) = font_atlas.as_deref() {
|
||||
if seam_moved || !script.has_text_measurer() {
|
||||
script.set_text_measurer(Box::new(crate::ui_text::AtlasMeasurer::new(
|
||||
atlas.engine(),
|
||||
s,
|
||||
)));
|
||||
seat_text_measurer(&mut script, atlas, s);
|
||||
}
|
||||
}
|
||||
// Phase spans (visible under `bevy/trace_chrome`): this system is the biggest flat CPU cost
|
||||
@@ -1327,6 +1346,9 @@ fn convert_entry(
|
||||
position,
|
||||
own_alpha,
|
||||
icon,
|
||||
camera,
|
||||
light,
|
||||
fog,
|
||||
} => {
|
||||
use crate::portrait::PortraitSource;
|
||||
// A FILE pane (decision 2008): publish what the tile renderer needs — the pane's
|
||||
@@ -1359,6 +1381,18 @@ fn convert_entry(
|
||||
star_px_per_unit: 768.0 * diag * s * dpi,
|
||||
facing,
|
||||
position: Vec3::new(position.0, position.1, position.2),
|
||||
// The PERSPECTIVE leg's root (decision 2027), which is in model units,
|
||||
// not pixels: `T(pos · layoutScale) · R(facing) · S(s)` with
|
||||
// `s = G48·(5/3)·modelScale·layoutScale` — and `G48·(5/3)` is exactly
|
||||
// `√((4/3)²+1)/√(a²+1)`, the 4:3 renormalizer (camera-law §11.2). The
|
||||
// camera is carried through the same matrix, so both terms cancel for
|
||||
// framing; they are here because the record's near/far are NOT scaled
|
||||
// with them, and because the geometry has to be drawn somewhere.
|
||||
root_scale: (5.0 / 3.0) / diag * model_scale * layout,
|
||||
root_pos: Vec3::new(position.0, position.1, position.2) * layout,
|
||||
camera,
|
||||
light,
|
||||
fog,
|
||||
icon: icon.clone(),
|
||||
rect,
|
||||
z_key: eq.z,
|
||||
|
||||
@@ -17,6 +17,24 @@ fn setup() -> UiScript {
|
||||
s
|
||||
}
|
||||
|
||||
/// One `/who` row as the app's feed would have resolved it.
|
||||
fn who(name: &str, level: u32, class: &str, zone: &str) -> WhoInfo {
|
||||
WhoInfo {
|
||||
name: name.to_string(),
|
||||
guild: String::new(),
|
||||
level,
|
||||
race: "Human".to_string(),
|
||||
class: class.to_string(),
|
||||
zone: zone.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The name painted into who row `row`.
|
||||
fn who_name(s: &UiScript, row: u32) -> String {
|
||||
s.eval::<String>(&format!("return WhoFrameButton{row}Name:GetText()"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn friend(name: &str, level: u32, class: &str, area: &str, connected: bool) -> FriendInfo {
|
||||
FriendInfo {
|
||||
name: name.to_string(),
|
||||
@@ -437,6 +455,60 @@ fn the_who_dropdown_switches_the_variable_column() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **B365's retest, pinned.** The Who list's column headers sort, the same header clicked twice
|
||||
/// REVERSES, and the earlier click survives as a tie-breaker — the reference's seven-slot chain
|
||||
/// (`SortWho 0x5ad890`, decision 2030).
|
||||
///
|
||||
/// What makes this the *window's* test rather than the chain's: every assertion reads the painted
|
||||
/// row straight after `Click()`, with **no feed tick in between**. That can only pass if
|
||||
/// `WHO_LIST_UPDATE` fires synchronously from inside the binding, the way `SignalEvent` does — the
|
||||
/// half of the bug the director could see, since our old sort redrew a tick later and only ever
|
||||
/// ascended.
|
||||
#[test]
|
||||
fn the_who_headers_sort_and_a_repeated_click_reverses() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = setup();
|
||||
s.run("ShowWhoPanel()").unwrap();
|
||||
push(
|
||||
&mut s,
|
||||
SocialState {
|
||||
who: vec![
|
||||
who("Galas", 60, "Warrior", "Elwynn Forest"),
|
||||
who("Erdrin", 12, "Mage", "Elwynn Forest"),
|
||||
],
|
||||
who_total: 2,
|
||||
..Default::default()
|
||||
},
|
||||
"WHO_LIST_UPDATE",
|
||||
);
|
||||
let _ = s.take_social_requests();
|
||||
assert_eq!(who_name(&s, 1), "Galas", "the server's order, unsorted");
|
||||
|
||||
// Header 1 is Name (`FriendsFrame.xml:1313`).
|
||||
s.run("WhoFrameColumnHeader1:Click()").unwrap();
|
||||
assert_eq!(who_name(&s, 1), "Erdrin", "Name, ascending");
|
||||
assert_eq!(
|
||||
s.take_social_requests(),
|
||||
vec![SocialRequest::SortWho("name".to_string())],
|
||||
"and the app hears the click too"
|
||||
);
|
||||
|
||||
s.run("WhoFrameColumnHeader1:Click()").unwrap();
|
||||
assert_eq!(who_name(&s, 1), "Galas", "the same header again reverses");
|
||||
|
||||
// Header 3 is Level (`:1394`) — ascending puts the level 12 first.
|
||||
s.run("WhoFrameColumnHeader3:Click()").unwrap();
|
||||
assert_eq!(who_name(&s, 1), "Erdrin", "Level, ascending");
|
||||
s.run("WhoFrameColumnHeader3:Click()").unwrap();
|
||||
assert_eq!(who_name(&s, 1), "Galas", "and Level reverses too");
|
||||
|
||||
// Back to Name: promoted from behind, it keeps the descending direction it was left in
|
||||
// rather than flipping — the chain's memory, not a per-click toggle.
|
||||
s.run("WhoFrameColumnHeader1:Click()").unwrap();
|
||||
assert_eq!(who_name(&s, 1), "Galas", "Name, still descending");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The who buttons need a selected row, and selecting one enables both. A fresh answer clears
|
||||
/// the selection — row 3 of the last query is not row 3 of this one.
|
||||
#[test]
|
||||
|
||||
@@ -248,6 +248,26 @@ pub(crate) fn run_pending_entry_load(world: &mut World) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Install the host's font engine into `script` for the CURRENT raster seam, if the glyph atlas
|
||||
/// exists yet — the load edge's half of [`super::extract::seat_text_measurer`].
|
||||
///
|
||||
/// No atlas means no measure to be had (it bakes on the first `Update`, from the patch chain and
|
||||
/// the window's real `scale_factor`); the per-frame pass seats one on the first frame it appears,
|
||||
/// exactly as before.
|
||||
fn seat_text_measurer_for_load(world: &mut World, script: &mut UiScript) {
|
||||
let ui_scale = world
|
||||
.get_resource::<super::UiScaleCvar>()
|
||||
.map_or(1.0, |c| c.0);
|
||||
let h = {
|
||||
let mut q = world.query_filtered::<&Window, With<bevy::window::PrimaryWindow>>();
|
||||
q.single(world).map_or(0.0, Window::height)
|
||||
};
|
||||
let Some(atlas) = world.get_resource::<crate::ui_text::UiFontAtlas>() else {
|
||||
return;
|
||||
};
|
||||
super::extract::seat_text_measurer(script, atlas, super::seam_scale(h, ui_scale));
|
||||
}
|
||||
|
||||
/// Materialize the in-game UI for **this** session.
|
||||
///
|
||||
/// **Once per world entry, not once per process** (decision 1290). The reference builds the whole
|
||||
@@ -311,6 +331,16 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
.get_resource::<crate::cvars::CvarPersist>()
|
||||
.is_none_or(crate::cvars::CvarPersist::addon_version_check)
|
||||
});
|
||||
// **The VM's font engine, before the first `<OnLoad>` runs** (decision 2028). Every file the
|
||||
// walk below loads may measure the text it just set — the era's own tab law is
|
||||
// `label:GetStringWidth() + 40` at OnLoad, and the addon corpus writes the same pair — and a
|
||||
// `GetStringWidth` with no measurer installed answers 0. Seated only from the per-frame pass
|
||||
// (`extract::drive_script`, an `Update` system), a VM that is BORN and LOADED inside one
|
||||
// exclusive `PreUpdate` slot never sees it: that is exactly `ReloadUI()`, which mints a fresh
|
||||
// boot VM in `end_ui_session` and calls straight into here, so every `/reload` measured 0
|
||||
// through its whole load edge and only converged a frame later off whatever poll the caller
|
||||
// had written to survive it.
|
||||
seat_text_measurer_for_load(world, &mut script);
|
||||
let _ = load_ingame_ui(&mut script, identity.as_ref(), version_check);
|
||||
// The Minimap widget was born a moment ago with `MinimapState::default()`; seed its two live
|
||||
// zoom indices from the persisted CVars now, before anything reads them — the reference's own
|
||||
@@ -448,12 +478,24 @@ pub(crate) struct AddOnIdentity(pub(crate) Option<(String, String)>);
|
||||
/// three independent Bevy systems on one state edge cannot express that, and until this landed the
|
||||
/// flat write and the `AddOns.txt` write were exactly that.
|
||||
///
|
||||
/// **The layout cache is step three, and it was missing until B353.** The quote above has always
|
||||
/// carried `layout-cache.txt`; the body skipped it, because [`crate::ui_layout`] had hung its own
|
||||
/// saver off `OnExit(InWorld)` instead. Two things follow from being outside the tail, and the
|
||||
/// bug report is both of them: a `/reload` never leaves `InWorld` ([`run_pending_reload`] calls
|
||||
/// this function and the rebuild back to back), so that saver never ran on the root a player uses
|
||||
/// most; and on the roots where it did run it was racing [`end_ui_session`]'s VM replacement on
|
||||
/// the same unordered edge — measured (bevy 0.18) to move with nothing but registration
|
||||
/// positions. In the tail it is neither: one call, ahead of the replacement, on every root.
|
||||
/// (It is also the one step that keeps a writer *outside* this function: a debounced
|
||||
/// crash-save, which the reference has not got and which the next paragraph is not about.)
|
||||
///
|
||||
/// **There is no autosave**, deliberately: the reference has none (decision 1128, and
|
||||
/// `ds:0xb4b3f4` has three references image-wide). These are a handful of scalars a player toggles
|
||||
/// a few times a session, and every file is written whole from the live globals.
|
||||
pub(crate) fn shutdown_ui_state(script: &mut UiScript, identity: Option<&(String, String)>) {
|
||||
script.fire_event("PLAYER_LEAVING_WORLD", vec![]);
|
||||
script.fire_event("PLAYER_LOGOUT", vec![]);
|
||||
crate::ui_layout::save_now(script, identity);
|
||||
crate::ui_saved::save(script);
|
||||
addons::save_addon_variables(script, identity);
|
||||
addons::save_enable_state(script, identity);
|
||||
|
||||
@@ -499,6 +499,7 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
|
||||
ModelFileFacts {
|
||||
sequences: vec![seq(127, 1333, false), seq(0, 833, true), seq(1, 333, false)],
|
||||
bbox: ([0.0; 3], [0.0; 3]),
|
||||
cameras: 0,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -3964,3 +3964,114 @@ fn the_action_bars_page_toggles_the_real_bars() {
|
||||
assert_eq!(s.eval::<f64>("return CONTAINER_OFFSET_Y").unwrap(), 70.0);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
// ── The window tabs (decision 2028) ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// A measurer that models the one property `FixedWidthFont` throws away: **glyph advances step to
|
||||
/// whole physical pixels**, so a label is not proportionally the same width at two scales. Six
|
||||
/// units per character at the DRAWN raster size (`6 × scale`, rounded), divided back by the scale
|
||||
/// — the shape `ui_text::measurer` really has, small enough to predict by hand.
|
||||
struct SteppedFont(f32);
|
||||
|
||||
impl benilla_ui::script::TextMeasure for SteppedFont {
|
||||
fn measure(&mut self, req: &benilla_ui::script::MeasureRequest) -> (f32, f32, f32) {
|
||||
let per_glyph = (self.0 * req.scale).round();
|
||||
let natural = req.text.chars().count() as f32 * per_glyph / req.scale;
|
||||
(natural, 12.0, natural)
|
||||
}
|
||||
}
|
||||
|
||||
/// **Both tabs fit their labels once per show** — the era's width law (`MinimalTab.lua` l.7:
|
||||
/// label + 40) on the 1.12 tab template's own seat (`<OnShow>` → `PanelTemplates_TabResize`,
|
||||
/// decision 1993), with no OnUpdate poll behind it and no `fitted` latch. The engine's measurer
|
||||
/// answers `GetStringWidth` inside the Lua call that asks it, so one call is the whole fit.
|
||||
///
|
||||
/// **The seat is a show and not the era's OnLoad, and that is what the numbers below pin.**
|
||||
/// `GetStringWidth` answers in the region's OWN units, so its number depends on the owner's
|
||||
/// effective scale — and this window's scale is set by `OptionsFrame_UpdateScale` in the window's
|
||||
/// own `<OnShow>`, after every `<OnLoad>` has already run. At OnLoad these tabs measure at scale 1
|
||||
/// (64 and 76 here); on the show, behind the window's own OnShow, they measure at the drawn 0.78.
|
||||
#[test]
|
||||
fn the_two_option_tabs_fit_their_labels_at_the_drawn_scale() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_text_measurer(Box::new(SteppedFont(6.0)));
|
||||
let mut s = harness_on(s);
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.resolve();
|
||||
|
||||
let num = |s: &mut UiScript, expr: &str| -> f32 { s.eval(&format!("return {expr}")).unwrap() };
|
||||
assert_eq!(
|
||||
num(&mut s, "OptionsFrame:GetScale()"),
|
||||
0.78,
|
||||
"ERA_WINDOW_SCALE"
|
||||
);
|
||||
|
||||
// The law, against each tab's own live measure.
|
||||
for tab in ["OptionsFrameGameTab", "OptionsFrameAddOnsTab"] {
|
||||
let w = num(&mut s, &format!("{tab}:GetWidth()"));
|
||||
let l = num(&mut s, &format!("{tab}Text:GetStringWidth()"));
|
||||
assert!(
|
||||
(w - (l + 40.0)).abs() < 0.01,
|
||||
"{tab} is {w} wide; the era's law is its label ({l}) + 40"
|
||||
);
|
||||
}
|
||||
// …and the numbers themselves, which say WHEN it ran. At 0.78 a 6-unit glyph rasterizes at 5
|
||||
// physical px and reads back 5/0.78 units, so "Game" (4) is 25.64 and "AddOns" (6) is 38.46.
|
||||
// An OnLoad fit — before the window's OnShow set the scale — would have measured at 1 and left
|
||||
// these at 64 and 76, which is 3.3 and 15.5 units off the drawn label.
|
||||
let close = |a: f32, b: f32| (a - b).abs() < 0.01;
|
||||
assert!(close(
|
||||
num(&mut s, "OptionsFrameGameTab:GetWidth()"),
|
||||
65.641_03
|
||||
));
|
||||
assert!(close(
|
||||
num(&mut s, "OptionsFrameAddOnsTab:GetWidth()"),
|
||||
78.461_54
|
||||
));
|
||||
|
||||
// Nothing re-fits them afterwards: the same two numbers with four frames run, which is the
|
||||
// value the retired OnUpdate settle used to converge to and latch.
|
||||
for _ in 0..4 {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
assert!(close(
|
||||
num(&mut s, "OptionsFrameGameTab:GetWidth()"),
|
||||
65.641_03
|
||||
));
|
||||
assert!(close(
|
||||
num(&mut s, "OptionsFrameAddOnsTab:GetWidth()"),
|
||||
78.461_54
|
||||
));
|
||||
|
||||
// A re-show re-fits rather than latching — the stock template's own behaviour, and what lets
|
||||
// a tab follow the window's scale when the screen changes under it.
|
||||
s.run("HideUIPanel(OptionsFrame) ShowUIPanel(OptionsFrame)")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert!(close(
|
||||
num(&mut s, "OptionsFrameGameTab:GetWidth()"),
|
||||
65.641_03
|
||||
));
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The falsifier: **with no measurer installed**, the same one-shot measures 0 and each tab comes
|
||||
/// out 40 wide — the law's padding and nothing else. That is the engine-less truth (`benilla-ui`
|
||||
/// `script/measure.rs`: absent is a supported state, metrics stay 0 until the host's batch
|
||||
/// round-trip fills them), and it is why the fit is a *shown* frame's job: by then the app has
|
||||
/// seated `AtlasMeasurer` for certain, whatever order the boot took.
|
||||
#[test]
|
||||
fn without_a_seated_measurer_the_same_fit_reads_zero() {
|
||||
let mut s = harness_on(UiScript::new().unwrap());
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.resolve();
|
||||
for tab in ["OptionsFrameGameTab", "OptionsFrameAddOnsTab"] {
|
||||
let w: f32 = s.eval(&format!("return {tab}:GetWidth()")).unwrap();
|
||||
assert!(
|
||||
(w - 40.0).abs() < 0.01,
|
||||
"{tab} is {w} wide, not the bare 40"
|
||||
);
|
||||
}
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -51,10 +51,12 @@ fn shipped_gossip_frame_drives_end_to_end() {
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
|
||||
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
|
||||
// list passes load_xml and then loses the scrollbar silently.
|
||||
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
|
||||
// (BenillaScroll_Step is only this suite's pan driver). The window's own
|
||||
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
|
||||
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
|
||||
// a loader *warning*, not an error, so an under-loaded list passes load_xml
|
||||
// and then loses the scrollbar silently.
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
|
||||
@@ -256,10 +258,12 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
|
||||
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
|
||||
// list passes load_xml and then loses the scrollbar silently.
|
||||
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
|
||||
// (BenillaScroll_Step is only this suite's pan driver). The window's own
|
||||
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
|
||||
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
|
||||
// a loader *warning*, not an error, so an under-loaded list passes load_xml
|
||||
// and then loses the scrollbar silently.
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
|
||||
@@ -419,10 +423,12 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
|
||||
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
|
||||
// list passes load_xml and then loses the scrollbar silently.
|
||||
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
|
||||
// (BenillaScroll_Step is only this suite's pan driver). The window's own
|
||||
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
|
||||
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
|
||||
// a loader *warning*, not an error, so an under-loaded list passes load_xml
|
||||
// and then loses the scrollbar silently.
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
|
||||
@@ -478,8 +484,9 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
|
||||
.collect();
|
||||
s.set_measured_text_unwrapped(&answers);
|
||||
};
|
||||
// Frame 1: the labels are measured (their heights land for the NEXT tick — the round-trip is a
|
||||
// frame late, exactly as the tab-fit and quest-panel resizes already live with).
|
||||
// Frame 1: the labels are measured (their heights land for the NEXT tick — this harness drives
|
||||
// the host's BATCH round-trip by hand, which is a frame late by construction; the app's own
|
||||
// synchronous measurer closes the same loop inside `resolve`).
|
||||
answer_measures(&mut s);
|
||||
s.resolve();
|
||||
// Frame 2: the frame's own settle pass reads those measures and sizes each row.
|
||||
@@ -537,10 +544,12 @@ fn gossip_show_hide_plays_open_and_close_kits() {
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
|
||||
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
|
||||
// list passes load_xml and then loses the scrollbar silently.
|
||||
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
|
||||
// (BenillaScroll_Step is only this suite's pan driver). The window's own
|
||||
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
|
||||
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
|
||||
// a loader *warning*, not an error, so an under-loaded list passes load_xml
|
||||
// and then loses the scrollbar silently.
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
|
||||
@@ -583,10 +592,12 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
|
||||
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
|
||||
// list passes load_xml and then loses the scrollbar silently.
|
||||
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
|
||||
// (BenillaScroll_Step is only this suite's pan driver). The window's own
|
||||
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
|
||||
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
|
||||
// a loader *warning*, not an error, so an under-loaded list passes load_xml
|
||||
// and then loses the scrollbar silently.
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
|
||||
@@ -678,10 +689,12 @@ fn displacing_an_npc_window_ends_the_displaced_session() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
|
||||
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
|
||||
// list passes load_xml and then loses the scrollbar silently.
|
||||
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
|
||||
// (BenillaScroll_Step is only this suite's pan driver). The window's own
|
||||
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
|
||||
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
|
||||
// a loader *warning*, not an error, so an under-loaded list passes load_xml
|
||||
// and then loses the scrollbar silently.
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
|
||||
@@ -852,10 +865,12 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
|
||||
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
|
||||
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
|
||||
// list passes load_xml and then loses the scrollbar silently.
|
||||
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
|
||||
// (BenillaScroll_Step is only this suite's pan driver). The window's own
|
||||
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
|
||||
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
|
||||
// a loader *warning*, not an error, so an under-loaded list passes load_xml
|
||||
// and then loses the scrollbar silently.
|
||||
// Before BankFrame, not after: its close and purchase buttons inherit UIPanelCloseButton
|
||||
// and UIPanelButtonTemplate, and an `inherits=` is resolved at LOAD.
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
|
||||
@@ -51,6 +51,7 @@ pub(super) fn cooldown_facts(s: &mut UiScript) {
|
||||
ModelFileFacts {
|
||||
sequences: vec![seq(0, 1000), seq(1, 1000)],
|
||||
bbox: ([0.0; 3], [0.0; 3]),
|
||||
cameras: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -964,3 +964,198 @@ fn a_repeating_error_is_one_row_with_a_count_not_a_flood() {
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
// ── B353 · the layout cache is a resident of the shutdown tail ───────────────────────────────
|
||||
//
|
||||
// st1rk, 2026-09-01: *"Unlock a chat window (right-click tab → Unlock Window), resize or drag it,
|
||||
// `/logout` or `/reload`, log back in. It's back at the original size. `benilla-config/layout/`
|
||||
// isnt created."* The engine seam and the file round trip were already proven by
|
||||
// [`crate::ui_script::chat_resize_tests::the_geometry_round_trips_through_the_save_file`]; what
|
||||
// was wrong is which edge writes. [`crate::ui_layout`] hung its saver off `OnExit(InWorld)`, and a
|
||||
// `/reload` never leaves `InWorld` — [`super::run_pending_reload`] calls the shutdown and the
|
||||
// rebuild back to back — so on the root a player uses most nothing was written at all. These two
|
||||
// drive the roots themselves, which is the only place that distinction is visible.
|
||||
|
||||
/// A window the player has placed, made the way a drag makes one: movable first, then the
|
||||
/// userPlaced bit (`SetUserPlaced` refuses a frame that is neither movable nor resizable).
|
||||
/// Parentless, so its anchor is the screen root — the file's `-` target — which is what lets this
|
||||
/// need no FrameXML and no install.
|
||||
fn place_a_window(world: &mut World) {
|
||||
world
|
||||
.get_non_send_resource_mut::<benilla_ui::script::UiScript>()
|
||||
.expect("a VM to place a window in")
|
||||
.run(
|
||||
"local f = CreateFrame(\"Frame\", \"B353Probe\") \
|
||||
f:SetWidth(413) f:SetHeight(147) \
|
||||
f:SetPoint(\"BOTTOMLEFT\", 61, 29) \
|
||||
f:SetMovable(true) f:SetUserPlaced(true)",
|
||||
)
|
||||
.expect("place the probe window");
|
||||
}
|
||||
|
||||
/// The layout cache the shutdown left behind for this character, if any.
|
||||
fn layout_cache(character: &str) -> Option<String> {
|
||||
let path = crate::local_state::layout_character_path("Realm", character)?;
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
/// What a saved window's row has to say for the player to get it back.
|
||||
fn assert_probe_row(text: &str) {
|
||||
for want in [
|
||||
"Frame: B353Probe",
|
||||
"W: 413",
|
||||
"H: 147",
|
||||
"Point: BOTTOMLEFT - BOTTOMLEFT 61 29",
|
||||
] {
|
||||
assert!(
|
||||
text.contains(want),
|
||||
"the saved row is missing `{want}`:\n{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **Logging out writes the window's geometry** — the tail's step three, on the root that leaves
|
||||
/// the world.
|
||||
#[test]
|
||||
fn a_placed_window_is_written_at_logout() {
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_probe("layout-logout");
|
||||
let mut world = booted_world();
|
||||
|
||||
log_in_as(&mut world, "Onehunter", 1);
|
||||
place_a_window(&mut world);
|
||||
assert!(
|
||||
layout_cache("Onehunter").is_none(),
|
||||
"nothing is written while the session is running"
|
||||
);
|
||||
|
||||
super::end_ui_session(&mut world);
|
||||
assert_probe_row(&layout_cache("Onehunter").expect("the logout wrote the layout cache"));
|
||||
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// **`/reload` writes it too — B353 itself.** The reload root never leaves `InWorld`, so it is
|
||||
/// exactly the root an `OnExit(InWorld)` saver cannot see: pre-fix this finds no file at all, and
|
||||
/// the player's unlocked chat window comes back on its authored anchors.
|
||||
#[test]
|
||||
fn a_placed_window_is_written_at_reload() {
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_probe("layout-reload");
|
||||
let mut world = booted_world();
|
||||
|
||||
log_in_as(&mut world, "Onehunter", 1);
|
||||
place_a_window(&mut world);
|
||||
reload(&mut world, crate::char_select::ClientState::InWorld);
|
||||
|
||||
assert_probe_row(&layout_cache("Onehunter").expect(
|
||||
"the reload wrote the layout cache — it runs the shutdown tail without a state edge",
|
||||
));
|
||||
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// The file is **per character**, and the tail writes back to the one the UI loaded under: a
|
||||
/// second character's logout must not answer with the first one's windows.
|
||||
#[test]
|
||||
fn each_character_gets_its_own_layout_cache() {
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_probe("layout-two-chars");
|
||||
let mut world = booted_world();
|
||||
|
||||
log_in_as(&mut world, "Onehunter", 1);
|
||||
place_a_window(&mut world);
|
||||
super::end_ui_session(&mut world);
|
||||
|
||||
log_in_as(&mut world, "Onewarrior", 2);
|
||||
super::end_ui_session(&mut world);
|
||||
|
||||
assert_probe_row(&layout_cache("Onehunter").expect("the first character's file"));
|
||||
let second = layout_cache("Onewarrior").expect("the second character's file");
|
||||
assert!(
|
||||
!second.contains("B353Probe"),
|
||||
"a character who placed nothing must not inherit another's window:\n{second}"
|
||||
);
|
||||
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// A window as FrameXML would author it — the shape the restore has to overwrite. Same name as
|
||||
/// [`place_a_window`]'s, different geometry, and **not** user-placed: this is the fresh tree a
|
||||
/// relog meets.
|
||||
fn author_a_window(world: &mut World) {
|
||||
world
|
||||
.get_non_send_resource_mut::<benilla_ui::script::UiScript>()
|
||||
.expect("a VM to author a window in")
|
||||
.run(
|
||||
"local f = CreateFrame(\"Frame\", \"B353Probe\") \
|
||||
f:SetWidth(100) f:SetHeight(100) \
|
||||
f:SetPoint(\"BOTTOMLEFT\", 0, 0) \
|
||||
f:SetMovable(true)",
|
||||
)
|
||||
.expect("author the probe window");
|
||||
}
|
||||
|
||||
/// The probe window's live geometry, as the player sees it.
|
||||
fn window_geometry(world: &World) -> (f32, f32, String, f32, f32) {
|
||||
world
|
||||
.get_non_send_resource::<benilla_ui::script::UiScript>()
|
||||
.expect("a VM")
|
||||
.eval::<(f32, f32, String, f32, f32)>(
|
||||
"local p, _, _, x, y = B353Probe:GetPoint(1) \
|
||||
return B353Probe:GetWidth(), B353Probe:GetHeight(), p, x, y",
|
||||
)
|
||||
.expect("read the probe window back")
|
||||
}
|
||||
|
||||
/// **The whole loop, on the root that reported it** — st1rk's retest, in one test: place a window,
|
||||
/// `/reload`, meet a fresh tree that has it on its authored anchors, and let the loader seat the
|
||||
/// saved geometry back over the top.
|
||||
///
|
||||
/// [`crate::ui_layout::load_layout`] is run directly because this harness drives the world's edges
|
||||
/// rather than its schedules; the authored window stands in for the FrameXML the real reload
|
||||
/// rebuilds.
|
||||
#[test]
|
||||
fn a_placed_window_comes_back_after_a_reload() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_probe("layout-roundtrip");
|
||||
let mut world = booted_world();
|
||||
world.init_resource::<crate::ui_layout::LayoutFile>();
|
||||
|
||||
log_in_as(&mut world, "Onehunter", 1);
|
||||
place_a_window(&mut world);
|
||||
let placed = window_geometry(&world);
|
||||
|
||||
reload(&mut world, crate::char_select::ClientState::InWorld);
|
||||
author_a_window(&mut world);
|
||||
assert_ne!(
|
||||
window_geometry(&world),
|
||||
placed,
|
||||
"the rebuilt tree starts on its authored anchors — otherwise this proves nothing"
|
||||
);
|
||||
|
||||
world
|
||||
.run_system_once(crate::ui_layout::load_layout)
|
||||
.expect("the layout loader ran");
|
||||
assert_eq!(
|
||||
window_geometry(&world),
|
||||
placed,
|
||||
"the window the player placed is back where they left it"
|
||||
);
|
||||
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ fn arrow_facts(s: &mut UiScript) {
|
||||
looping: true,
|
||||
}],
|
||||
bbox: ([-0.0127, -0.0118, 0.0], [0.0135, 0.0145, 0.0]),
|
||||
cameras: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,21 @@ const WHO_LIST_GUILD_FORMAT: &str = "|Hplayer:%s|h[%s]|h: Level %d %s %s <%s> -
|
||||
const WHO_NUM_RESULTS: &str = "%d player total";
|
||||
const WHO_NUM_RESULTS_P1: &str = "%d players total";
|
||||
|
||||
/// The chat-frame threshold on an answer the Who frame did not claim. `SMSG_WHO`'s parser
|
||||
/// (`0x5adf60`) computes one **print flag** before its record loop (`0x5adf9c`–`0x5adfd5`) and
|
||||
/// that flag gates both halves: with `SetWhoToUI` set the event always fires; with it clear,
|
||||
/// `0x5adfca cmp eax,ecx` / `0x5adfcc jl` — signed, `eax = 3` — sends **more than three** rows to
|
||||
/// `WHO_LIST_UPDATE` (the frame holds them for whenever it opens, printing nothing) and three or
|
||||
/// fewer to the chat frame, with no event. Four hits go to the frame; three go to chat. The
|
||||
/// threshold is the literal `3`: the pointer that could override it (`[[0xc2a128]+0x28]`) has one
|
||||
/// reference image-wide and no writer.
|
||||
///
|
||||
/// **`ecx` is the RAW wire display count** (`[ebp-0x14]`), not the 50-capped global the cap at
|
||||
/// `0x5adf92` writes — a re-implementation must not test its own clamped count and call it the
|
||||
/// same rule. Ours is [`SocialState::who`]'s length, which is the wire's count uncapped (vmangos
|
||||
/// sends at most 49), so the two agree. wow-re `who-list-sort-law.md` §11.3, decision 2030.
|
||||
const WHO_CHAT_MAX: usize = 3;
|
||||
|
||||
/// What the feed last announced, so the Era events fire on edges rather than every frame.
|
||||
#[derive(Default)]
|
||||
pub(super) struct FedSocial {
|
||||
@@ -60,7 +75,7 @@ pub(super) fn feed_social(
|
||||
|
||||
let (friends, display_order) = friend_rows(&social, &mut names, &commands, areas);
|
||||
let (ignores, ignore_order) = ignore_rows(&social, &mut names, &commands);
|
||||
let who: Vec<WhoInfo> = social.who.iter().map(|e| who_row(e, areas)).collect();
|
||||
let who = who_rows(&social, areas);
|
||||
|
||||
let selected_friend = index_of(&display_order, social.selected_friend);
|
||||
let selected_ignore = index_of(&ignore_order, social.selected_ignore);
|
||||
@@ -74,6 +89,9 @@ pub(super) fn feed_social(
|
||||
selected_ignore,
|
||||
who,
|
||||
who_total: social.who_total,
|
||||
// The chain rides along because `SortWho` promotes and re-sorts inside the binding — see
|
||||
// its comment in `benilla_ui::script::social`.
|
||||
who_sort: social.who_sort.clone(),
|
||||
});
|
||||
|
||||
// The three list events (`FriendsFrame_OnEvent`'s own arms). FRIENDLIST_SHOW is the answer to
|
||||
@@ -93,17 +111,32 @@ pub(super) fn feed_social(
|
||||
social.ignores_dirty = false;
|
||||
script.fire_event("IGNORELIST_UPDATE", Vec::new());
|
||||
}
|
||||
// `SMSG_WHO`'s two exits ([`WHO_CHAT_MAX`]). Note the event is the answer's *only* announcement
|
||||
// — `SortWho` fires its own, synchronously, from inside the binding — so a sort no longer
|
||||
// re-announces the list a tick later (decision 2030).
|
||||
if social.who_dirty {
|
||||
social.who_dirty = false;
|
||||
if social.who_to_ui {
|
||||
if answer_goes_to_the_frame(social.who_to_ui, social.who.len()) {
|
||||
script.fire_event("WHO_LIST_UPDATE", Vec::new());
|
||||
} else {
|
||||
// The frame isn't listening — the engine prints the results instead.
|
||||
push_who_lines(&social, &mut chat_log, areas);
|
||||
// Nobody is holding the list — the engine prints the results itself, **in wire
|
||||
// order**: the per-record line is composed inside the parse loop (`0x5ae0a1`, one
|
||||
// call per record) and the `qsort` at `0x5ae0e2` sits past the loop's back-edge, so
|
||||
// it reorders only the array `GetWhoInfo` reads, never these lines.
|
||||
let wire_order: Vec<WhoInfo> = social.who.iter().map(|e| who_row(e, areas)).collect();
|
||||
for line in who_lines(&wire_order, social.who_total) {
|
||||
system_line(&mut chat_log, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which exit an `SMSG_WHO` answer takes: `true` fires `WHO_LIST_UPDATE` and prints nothing,
|
||||
/// `false` prints the chat lines and fires nothing ([`WHO_CHAT_MAX`]).
|
||||
fn answer_goes_to_the_frame(to_ui: bool, shown: usize) -> bool {
|
||||
to_ui || shown > WHO_CHAT_MAX
|
||||
}
|
||||
|
||||
/// Compose and push every result line whose name has resolved. A line needing a name waits (the
|
||||
/// reference's resolve-then-compose order); one that doesn't goes out immediately.
|
||||
fn drain_result_lines(
|
||||
@@ -134,18 +167,20 @@ fn drain_result_lines(
|
||||
social.pending_lines = still_pending;
|
||||
}
|
||||
|
||||
/// The chat-routed `/who` output: the total line, then one line per row (module doc's four
|
||||
/// engine-only templates).
|
||||
fn push_who_lines(social: &SocialState, chat_log: &mut ChatLog, areas: Option<&AreaTableCatalog>) {
|
||||
let total = social.who_total;
|
||||
/// The chat-routed `/who` output (module doc's four engine-only templates): one line per row **in
|
||||
/// the order given**, and the `WHO_NUM_RESULTS` total **last**.
|
||||
///
|
||||
/// The order is the parser's, not a presentation choice: `0x5ae0a1` composes a record's line
|
||||
/// inside the loop that reads it, and the summary block `0x5ae0f1`–`0x5ae12a` sits after the
|
||||
/// loop's back-edge (wow-re `who-list-sort-law.md` §11.4).
|
||||
fn who_lines(rows: &[WhoInfo], total: u32) -> Vec<String> {
|
||||
let template = if total == 1 {
|
||||
WHO_NUM_RESULTS
|
||||
} else {
|
||||
WHO_NUM_RESULTS_P1
|
||||
};
|
||||
system_line(chat_log, template.replace("%d", &total.to_string()));
|
||||
for entry in &social.who {
|
||||
let row = who_row(entry, areas);
|
||||
let mut lines = Vec::with_capacity(rows.len() + 1);
|
||||
for row in rows {
|
||||
let template = if row.guild.is_empty() {
|
||||
WHO_LIST_FORMAT
|
||||
} else {
|
||||
@@ -167,8 +202,10 @@ fn push_who_lines(social: &SocialState, chat_log: &mut ChatLog, areas: Option<&A
|
||||
line = replace_first_token(&line, &row.guild);
|
||||
}
|
||||
line = replace_first_token(&line, &row.zone);
|
||||
system_line(chat_log, line);
|
||||
lines.push(line);
|
||||
}
|
||||
lines.push(template.replace("%d", &total.to_string()));
|
||||
lines
|
||||
}
|
||||
|
||||
/// Replace the first `%s` or `%d` in `line` with `fill` — the templates interleave both, so a
|
||||
@@ -261,6 +298,20 @@ fn ignore_rows(
|
||||
rows.into_iter().map(|(guid, name)| (name, guid)).unzip()
|
||||
}
|
||||
|
||||
/// The `/who` rows, resolved and then ordered by the sort chain — the reference's own
|
||||
/// `qsort 0x5ae0e2` on every fresh answer, plus whatever a header click promoted since.
|
||||
///
|
||||
/// Recomputed from the wire mirror rather than kept sorted in place, because the comparator's
|
||||
/// three DBC arms compare **resolved names** (`ChrClasses`/`ChrRaces`/`AreaTable`), which only
|
||||
/// exist on this side of [`who_row`]. The result is the same either way: the chain always ends
|
||||
/// with the name key somewhere in it, and no two rows of one answer share a name, so the order is
|
||||
/// total — re-deriving it per frame lands on the identical list.
|
||||
fn who_rows(social: &SocialState, areas: Option<&AreaTableCatalog>) -> Vec<WhoInfo> {
|
||||
let mut rows: Vec<WhoInfo> = social.who.iter().map(|e| who_row(e, areas)).collect();
|
||||
social.who_sort.sort(&mut rows);
|
||||
rows
|
||||
}
|
||||
|
||||
/// One `/who` row, ids resolved. Note the Lua API returns race *before* class while the wire
|
||||
/// carries class first — the swap happens here, once.
|
||||
fn who_row(entry: &WhoEntry, areas: Option<&AreaTableCatalog>) -> WhoInfo {
|
||||
@@ -378,10 +429,11 @@ pub(super) fn drain_social(
|
||||
request: Box::new(request),
|
||||
});
|
||||
}
|
||||
SocialRequest::SortWho(sort_type) => {
|
||||
social.who_sort = sort_type;
|
||||
sort_who(&mut social);
|
||||
}
|
||||
// The click the binding already applied to the VM's copy of the chain, applied to
|
||||
// ours — so the next push agrees. No `who_dirty`: `SortWho` fired `WHO_LIST_UPDATE`
|
||||
// synchronously, inside the binding, and the reference fires it exactly once per
|
||||
// click (decision 2030).
|
||||
SocialRequest::SortWho(sort_type) => social.who_sort.promote(&sort_type),
|
||||
SocialRequest::SetWhoToUi(on) => social.who_to_ui = on,
|
||||
}
|
||||
}
|
||||
@@ -405,26 +457,146 @@ fn guid_named(order: &[u64], name: &str, names: &NameCache) -> Option<u64> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-order the who results in place. Client-side, like the reference's own comparator — the
|
||||
/// server sends them in whatever order its player map iterates.
|
||||
fn sort_who(social: &mut SocialState) {
|
||||
let key = social.who_sort.clone();
|
||||
match key.as_str() {
|
||||
"level" => social.who.sort_by_key(|e| e.level),
|
||||
"class" => social.who.sort_by_key(|e| e.class),
|
||||
"race" => social.who.sort_by_key(|e| e.race),
|
||||
"zone" => social.who.sort_by_key(|e| e.zone),
|
||||
"guild" => social.who.sort_by(|a, b| a.guild.cmp(&b.guild)),
|
||||
// "name" and anything unrecognised: alphabetical, the list's resting order.
|
||||
_ => social.who.sort_by(|a, b| a.name.cmp(&b.name)),
|
||||
}
|
||||
social.who_dirty = true;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_protocol::messages::friend_status;
|
||||
use benilla_protocol::messages::{friend_status, WhoResults};
|
||||
|
||||
fn entry(name: &str, level: u32, guild: &str) -> WhoEntry {
|
||||
WhoEntry {
|
||||
name: name.to_string(),
|
||||
guild: guild.to_string(),
|
||||
level,
|
||||
class: 1,
|
||||
race: 1,
|
||||
zone: 12,
|
||||
}
|
||||
}
|
||||
|
||||
fn names(rows: &[WhoInfo]) -> Vec<&str> {
|
||||
rows.iter().map(|r| r.name.as_str()).collect()
|
||||
}
|
||||
|
||||
/// **B365, app side.** A fresh `SMSG_WHO` answer is presented in the sort chain's order, not
|
||||
/// the server's — the reference qsorts inside the parse itself (`0x5ae0e2`), so a list sorted
|
||||
/// by level stays sorted by level when the next `/who` lands. And a header click's intent
|
||||
/// re-orders the *next* push without needing a fresh answer.
|
||||
#[test]
|
||||
fn the_answer_is_presented_in_the_sort_chains_order() {
|
||||
let mut social = SocialState::default();
|
||||
social.apply_who(WhoResults {
|
||||
displayed: 3,
|
||||
total: 3,
|
||||
entries: vec![
|
||||
entry("Galas", 60, ""),
|
||||
entry("erdrin", 12, ""),
|
||||
entry("Bruk", 60, ""),
|
||||
],
|
||||
});
|
||||
|
||||
// The seeded chain is zone → level → class → group → name → …: one zone and one class
|
||||
// here, so it resolves on level, then name.
|
||||
assert_eq!(
|
||||
names(&who_rows(&social, None)),
|
||||
["erdrin", "Bruk", "Galas"],
|
||||
"wire order was Galas, erdrin, Bruk"
|
||||
);
|
||||
|
||||
// A Name click: ascending, and case-folded — `erdrin` sorts among the capitals, not after
|
||||
// them as a byte compare would put it.
|
||||
social.who_sort.promote("name");
|
||||
assert_eq!(names(&who_rows(&social, None)), ["Bruk", "erdrin", "Galas"]);
|
||||
|
||||
// The same click again reverses.
|
||||
social.who_sort.promote("name");
|
||||
assert_eq!(names(&who_rows(&social, None)), ["Galas", "erdrin", "Bruk"]);
|
||||
|
||||
// A fresh answer arrives into that same chain.
|
||||
social.apply_who(WhoResults {
|
||||
displayed: 2,
|
||||
total: 2,
|
||||
entries: vec![entry("Aaa", 1, ""), entry("Zzz", 1, "")],
|
||||
});
|
||||
assert_eq!(names(&who_rows(&social, None)), ["Zzz", "Aaa"]);
|
||||
}
|
||||
|
||||
/// The two exits of the `SMSG_WHO` parse. The threshold is what makes a broad `/who` typed
|
||||
/// with the window shut go quiet instead of dumping fifty lines into the chat frame.
|
||||
#[test]
|
||||
fn a_small_answer_goes_to_chat_and_a_large_one_to_the_frame() {
|
||||
assert!(!answer_goes_to_the_frame(false, 0));
|
||||
assert!(!answer_goes_to_the_frame(false, 3), "three still print");
|
||||
assert!(answer_goes_to_the_frame(false, 4), "four go to the frame");
|
||||
// With the frame open it is always the frame's, however few hits there are.
|
||||
for shown in 0..=4 {
|
||||
assert!(answer_goes_to_the_frame(true, shown));
|
||||
}
|
||||
}
|
||||
|
||||
/// The chat lines come out in **wire** order with the total **last** — and they are the one
|
||||
/// half of the who list the sort chain does not touch, however hard that reads as an
|
||||
/// inconsistency.
|
||||
///
|
||||
/// The reference composes a record's line inside the loop that parses it (`0x5ae0a1`), and
|
||||
/// the `qsort` that orders the array sits past that loop's back-edge (`0x5ae0e2`) — so the
|
||||
/// lines are already gone by the time anything is sorted, and only the array `GetWhoInfo`
|
||||
/// reads is ordered. Sorting them "for consistency" is the plausible wrong answer, and it was
|
||||
/// this client's until wow-re read the call site (`who-list-sort-law.md` §11.4).
|
||||
#[test]
|
||||
fn the_chat_lines_come_out_in_wire_order_with_the_total_last() {
|
||||
let mut social = SocialState::default();
|
||||
social.apply_who(WhoResults {
|
||||
displayed: 2,
|
||||
total: 2,
|
||||
entries: vec![entry("Zzz", 60, ""), entry("Aaa", 12, "")],
|
||||
});
|
||||
// A chain that would order them the other way round, to prove it is not consulted.
|
||||
social.who_sort.promote("name");
|
||||
assert_eq!(
|
||||
names(&who_rows(&social, None)),
|
||||
["Aaa", "Zzz"],
|
||||
"the frame's order"
|
||||
);
|
||||
|
||||
let wire: Vec<WhoInfo> = social.who.iter().map(|e| who_row(e, None)).collect();
|
||||
let lines = who_lines(&wire, social.who_total);
|
||||
assert_eq!(lines.len(), 3, "one per row plus the total: {lines:?}");
|
||||
assert!(
|
||||
lines[0].contains("Zzz"),
|
||||
"wire order, not the chain's: {lines:?}"
|
||||
);
|
||||
assert!(lines[1].contains("Aaa"), "{lines:?}");
|
||||
assert_eq!(lines[2], "2 players total", "the summary comes last");
|
||||
}
|
||||
|
||||
/// The comparator's "missing DBC row" marker is the **empty** name, and it must stay that
|
||||
/// way: `who_row` leaves an unresolvable class/race/zone empty, and the chain ties on it
|
||||
/// rather than ordering it (wow-re §11.1 — the arm jumps to the loop's `inc esi`).
|
||||
///
|
||||
/// **This test is a tripwire.** The reference's `GetWhoInfo 0x5ad6e0` substitutes the
|
||||
/// localized `"UNKNOWN"` on those same three legs (§11.2), so the two sides deliberately
|
||||
/// disagree: the cell reads UNKNOWN and the row sorts as if the column were not there. If
|
||||
/// this client ever adopts that substitution — it should; ours shows an empty cell today —
|
||||
/// the miss has to travel to the comparator by some other route than the string, or the tie
|
||||
/// silently becomes an alphabetical sort on the word "Unknown".
|
||||
#[test]
|
||||
fn an_unresolvable_id_leaves_the_name_empty_for_the_comparator() {
|
||||
let row = who_row(
|
||||
&WhoEntry {
|
||||
name: "Nobody".into(),
|
||||
guild: String::new(),
|
||||
level: 1,
|
||||
class: 200,
|
||||
race: 200,
|
||||
zone: 999_999,
|
||||
},
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
(row.class.as_str(), row.race.as_str(), row.zone.as_str()),
|
||||
("", "", "")
|
||||
);
|
||||
}
|
||||
|
||||
/// A row index maps back through the *shown* order, and 0/past-the-end map to nothing — the
|
||||
/// guard that keeps a stale click from removing a bystander.
|
||||
|
||||
@@ -100,8 +100,10 @@ pub(crate) struct SocialState {
|
||||
/// The last `/who` answer, and the server's true match total.
|
||||
who: Vec<benilla_protocol::messages::WhoEntry>,
|
||||
who_total: u32,
|
||||
/// The current who-list sort key (`SortWho`), `"zone"` being the frame's own default column.
|
||||
who_sort: String,
|
||||
/// The `/who` sort chain (`SortWho`'s seven `{key, dir}` slots at `0xc2817c`) — the app's
|
||||
/// authoritative copy, pushed to the VM with the rows so the binding can promote and re-sort
|
||||
/// synchronously. **Survives a logout** ([`SocialState::clear_session`]).
|
||||
who_sort: benilla_ui::script::WhoSortChain,
|
||||
/// `SetWhoToUI` — does the *next* `/who` answer belong to the Who frame (true) or the chat
|
||||
/// frame (false)? The WhoFrame's OnShow/OnHide drive it; a `/who` typed with the frame closed
|
||||
/// prints its results as chat lines.
|
||||
@@ -119,6 +121,21 @@ pub(crate) struct SocialState {
|
||||
}
|
||||
|
||||
impl SocialState {
|
||||
/// Drop everything the socket owned, keeping what the *process* owns.
|
||||
///
|
||||
/// Only [`Self::who_sort`] survives, and it survives because the reference's chain is
|
||||
/// per-process: its initialiser `0x5adc50` is reached once from the process-start run at
|
||||
/// `0x401666`, never from a login, so a player who left the who list sorted by level
|
||||
/// descending finds it that way after a relog (wow-re `who-list-sort-law.md` §3). Everything
|
||||
/// else is login-scoped for the reasons decision 0668 gives — the server re-pushes both lists
|
||||
/// at the next login, and a stale ignore list would silence the wrong guids.
|
||||
pub(crate) fn clear_session(&mut self) {
|
||||
*self = Self {
|
||||
who_sort: std::mem::take(&mut self.who_sort),
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
|
||||
/// Is `guid` on the ignore list? The reference's `FriendList::IsIgnored 0x5ae5a0` — the
|
||||
/// predicate inbound chat, text emotes and duel challenges all gate on (module doc).
|
||||
pub(crate) fn is_ignored(&self, guid: u64) -> bool {
|
||||
@@ -450,6 +467,35 @@ mod tests {
|
||||
assert_eq!(result_template(0x77), None, "an unknown code shows nothing");
|
||||
}
|
||||
|
||||
/// A logout drops the lists and the last `/who`, and keeps the **sort chain** — which is
|
||||
/// per-process in the reference, not per-login, so a player who left the who list sorted by
|
||||
/// level descending finds it that way after a relog.
|
||||
#[test]
|
||||
fn a_logout_keeps_the_sort_chain_and_drops_everything_else() {
|
||||
let mut social = SocialState::default();
|
||||
social.apply_friend_list(vec![FriendEntry {
|
||||
guid: 7,
|
||||
..Default::default()
|
||||
}]);
|
||||
social.apply_ignore_list(vec![9]);
|
||||
social.who_to_ui = true;
|
||||
social.who_sort.promote("level");
|
||||
social.who_sort.promote("level"); // descending
|
||||
let chain = social.who_sort.clone();
|
||||
|
||||
social.clear_session();
|
||||
assert!(social.friends.is_empty());
|
||||
assert!(social.ignores.is_empty());
|
||||
assert!(social.who.is_empty());
|
||||
assert!(!social.who_to_ui, "the frame is closed after a logout");
|
||||
assert_eq!(social.who_sort, chain, "the sort chain is process state");
|
||||
assert_ne!(
|
||||
social.who_sort,
|
||||
benilla_ui::script::WhoSortChain::default(),
|
||||
"and the assertion above only means something if it is not the seeded chain"
|
||||
);
|
||||
}
|
||||
|
||||
/// The online line takes the name twice (the player link); the rest take it once.
|
||||
#[test]
|
||||
fn the_online_line_fills_the_name_twice() {
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
//! References use `.mdx`/`.mdl`, but the physical archive file is `.m2`; callers map the extension when
|
||||
//! forming the `mpq://…m2` load path (the loader registers for `m2`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use benilla_formats::{
|
||||
hand_grip_finger_poses, parse_m2_animations, parse_m2_attachments, parse_m2_bounds,
|
||||
parse_m2_collision_hull, parse_m2_global_sequence_bones, parse_m2_lights,
|
||||
@@ -118,6 +120,13 @@ pub struct M2Model {
|
||||
/// model with fewer than two cameras, where the client synthesizes a *fixed* camera instead
|
||||
/// (transcribed at the framing site). Same Bevy-space conversion as `portrait_camera`.
|
||||
pub pane_camera: Option<PortraitCamera>,
|
||||
/// The **whole camera table**, in file order — the raw index space a `<Model>` widget's
|
||||
/// `SetCamera(n)` walks (decision 2027). `camera0` and `pane_camera` above are the two fixed
|
||||
/// indices two other paths take; this is the general one, and it is the only one that can
|
||||
/// answer "how many cameras does this file have", which is the question that decides whether
|
||||
/// a pane renders through the perspective leg at all (an index past the count installs a NULL
|
||||
/// camera and the pane falls back to the orthographic leg).
|
||||
pub cameras: Vec<PaneCamera>,
|
||||
/// A bow's `$WTT`/`$WTB` bowstring anchors (wow-re `nocked-ammo-cancel.md` §G2), baked to Bevy
|
||||
/// space: `[top, bottom]` as `(bone index, model-local position)` — the two limb-tip points the
|
||||
/// engine-drawn string spans. `None` for every non-bow model.
|
||||
@@ -143,6 +152,11 @@ pub struct M2SequenceInfo {
|
||||
pub seq_index: usize,
|
||||
/// `end − start` on the file's timeline, ms.
|
||||
pub duration_ms: u32,
|
||||
/// The band's **start** on the file's global timeline, ms (`M2Sequence+0x04`) — what a
|
||||
/// per-sequence cursor is added to before an absolute-timeline track is sampled. The camera
|
||||
/// tracks are the one consumer: their keys are absolute like every other M2 track's, while a
|
||||
/// pane's play head is a cursor inside the armed band.
|
||||
pub start_ms: u32,
|
||||
/// Loops (flag bit 0 clear); a clamped sequence holds its last frame.
|
||||
pub looping: bool,
|
||||
}
|
||||
@@ -166,6 +180,49 @@ pub struct PortraitCamera {
|
||||
pub far: f32,
|
||||
}
|
||||
|
||||
/// One record of the file's camera table, by **raw index** — see [`M2Model::cameras`].
|
||||
///
|
||||
/// The rest rig ([`Self::still`]) is Bevy space like every other camera on this asset; the
|
||||
/// authored tracks stay raw WoW space behind [`Self::at`], which does the conversion, so there is
|
||||
/// no mixed-convention field to misread.
|
||||
#[derive(Clone)]
|
||||
pub struct PaneCamera {
|
||||
/// The record's `type` word — `0` portrait, `1` "characterinfo", `-1` on the fly-bys and the
|
||||
/// six glue scenes. Carried, never selected on: the widget's index is raw.
|
||||
pub camera_type: i32,
|
||||
/// The rig at rest (bases + each track's first key), Bevy space. The whole answer for every
|
||||
/// model a `<Model>` pane can name — wow-re's census over the composite's 9691 `.m2` found
|
||||
/// every one of those cameras keying a single `(0,0,0)` on all three tracks.
|
||||
pub still: PortraitCamera,
|
||||
/// The authored tracks, raw WoW space, kept only when one of them actually moves — the
|
||||
/// `Cameras\*.m2` fly-bys. Read through [`Self::at`].
|
||||
tracks: Option<Arc<benilla_formats::M2CameraTracks>>,
|
||||
}
|
||||
|
||||
impl PaneCamera {
|
||||
/// The rig at absolute file-timeline `ms`, Bevy space — [`Self::still`] for a static camera,
|
||||
/// the cubically sampled tracks for a moving one (`benilla_m2::M2Track::sample_ms`, the
|
||||
/// reference's own four-way `interp` dispatch).
|
||||
///
|
||||
/// `wow_to_bevy` is linear, so converting the summed `base + track(t)` is the same as
|
||||
/// converting each — which is why the tracks can stay raw behind this one door.
|
||||
pub fn at(&self, ms: u32) -> PortraitCamera {
|
||||
let Some(t) = self.tracks.as_deref() else {
|
||||
return self.still;
|
||||
};
|
||||
let add = |b: [f32; 3], v: Option<[f32; 3]>| {
|
||||
let v = v.unwrap_or([0.0; 3]);
|
||||
wow_to_bevy([b[0] + v[0], b[1] + v[1], b[2] + v[2]])
|
||||
};
|
||||
PortraitCamera {
|
||||
eye: add(t.position_base, t.positions.sample_ms(ms)),
|
||||
target: add(t.target_base, t.target.sample_ms(ms)),
|
||||
roll: t.roll.sample_ms(ms).unwrap_or(self.still.roll),
|
||||
..self.still
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The **billboard frame** an emitter's bone chain reaches — the host bone's arm, its pivot in raw
|
||||
/// WoW model space, and **which bone it is**. See [`ModelEmitter::billboard`] for when it is `Some`
|
||||
/// and why the emitter's live origin is then `pivot + camBasis·(def.position − pivot)`.
|
||||
@@ -535,6 +592,7 @@ impl AssetLoader for M2ModelLoader {
|
||||
anim_id: a.anim_id,
|
||||
seq_index: a.seq_index,
|
||||
duration_ms: a.end_ms.saturating_sub(a.start_ms),
|
||||
start_ms: a.start_ms,
|
||||
looping: a.looping,
|
||||
})
|
||||
.collect();
|
||||
@@ -805,6 +863,15 @@ impl AssetLoader for M2ModelLoader {
|
||||
let portrait_camera = parse_m2_portrait_camera(&bytes).map(to_bevy);
|
||||
let camera0 = benilla_formats::parse_m2_camera(&bytes, 0).map(to_bevy);
|
||||
let pane_camera = benilla_formats::parse_m2_camera(&bytes, 1).map(to_bevy);
|
||||
// The whole table, raw index — the `<Model>` widget's `SetCamera(n)` space (2027).
|
||||
let cameras: Vec<PaneCamera> = benilla_formats::parse_m2_pane_cameras(&bytes)
|
||||
.into_iter()
|
||||
.map(|c| PaneCamera {
|
||||
camera_type: c.camera_type,
|
||||
still: to_bevy(c.still),
|
||||
tracks: c.tracks.map(|t| Arc::new(*t)),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The bowstring anchors (bows only): raw WoW positions → Bevy, the meshes' frame.
|
||||
let string_anchors = benilla_formats::parse_m2_string_anchors(&bytes).map(|a| {
|
||||
@@ -840,6 +907,7 @@ impl AssetLoader for M2ModelLoader {
|
||||
portrait_camera,
|
||||
camera0,
|
||||
pane_camera,
|
||||
cameras,
|
||||
string_anchors,
|
||||
cch_marker,
|
||||
global_flags,
|
||||
|
||||
@@ -1536,3 +1536,60 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dump an M2's **camera table**, in raw file-index order — the index space a `<Model>` widget's
|
||||
/// `Model:SetCamera(n)` walks (decision 2027; the selection is raw, `cameraLookup` is not
|
||||
/// consulted there, so the table position IS the answer and the record's `type` is not).
|
||||
///
|
||||
/// Prints, per record: its `type` word, the eye and look-at target at rest (`base + key 0`, raw
|
||||
/// WoW model space), the diagonal fov in radians and degrees, near/far, the roll, and the key
|
||||
/// count of each of the three tracks — the last column being the one that says whether the camera
|
||||
/// is a still rig (every character/creature/interface camera in the chain) or an authored path
|
||||
/// (the `Cameras\*.m2` fly-bys). The `cameraLookup` table is printed beside it, because the
|
||||
/// portrait bake selects through it and the pane does not, and confusing the two is a one-line
|
||||
/// mistake with a wrong picture at the end of it.
|
||||
pub fn m2cam(chain: &mut Chain, internal_path: &str) -> Result<()> {
|
||||
let name = normalize(internal_path);
|
||||
let data = chain
|
||||
.read_file(&name)
|
||||
.with_context(|| format!("reading '{name}' from chain"))?;
|
||||
let cams = benilla_formats::parse_m2_pane_cameras(&data);
|
||||
let lookup = benilla_m2::parse_camera_lookup(&data);
|
||||
println!("{name}: {} camera(s), cameraLookup {lookup:?}", cams.len());
|
||||
if cams.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
println!(
|
||||
"idx type eye target fov(rad/deg) near far roll keys p/t/r"
|
||||
);
|
||||
for (i, c) in cams.iter().enumerate() {
|
||||
let s = &c.still;
|
||||
let keys = c.tracks.as_deref().map_or([1, 1, 1], |t| {
|
||||
[
|
||||
t.positions.keys.len(),
|
||||
t.target.keys.len(),
|
||||
t.roll.keys.len(),
|
||||
]
|
||||
});
|
||||
println!(
|
||||
"{i:>3} {:>4} ({:>8.4},{:>8.4},{:>8.4}) ({:>8.4},{:>8.4},{:>8.4}) {:.5}/{:>6.2} {:>7.4} {:>7.3} {:>5.3} {}/{}/{}{}",
|
||||
c.camera_type,
|
||||
s.position[0],
|
||||
s.position[1],
|
||||
s.position[2],
|
||||
s.target[0],
|
||||
s.target[1],
|
||||
s.target[2],
|
||||
s.fov,
|
||||
s.fov.to_degrees(),
|
||||
s.near_clip,
|
||||
s.far_clip,
|
||||
s.roll,
|
||||
keys[0],
|
||||
keys[1],
|
||||
keys[2],
|
||||
if c.tracks.is_some() { " ANIMATED" } else { "" },
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -172,6 +172,15 @@ enum Command {
|
||||
/// Internal path to the `.m2` (forward or back slashes accepted).
|
||||
internal_path: String,
|
||||
},
|
||||
/// Dump an M2's **camera table** by raw file index — the index space a `<Model>` widget's
|
||||
/// `Model:SetCamera(n)` walks (decision 2027): type, rest eye/target, diagonal fov, near/far,
|
||||
/// roll, and each track's key count (which says still rig vs authored path). The
|
||||
/// `cameraLookup` table is printed beside it — the portrait bake selects through that, the
|
||||
/// pane does not.
|
||||
M2cam {
|
||||
/// Internal path to the `.m2` (forward or back slashes accepted).
|
||||
internal_path: String,
|
||||
},
|
||||
/// Dump an M2's animation EVENT keyframes per sequence (`$CSS`/`$CAH`/`$AH0-3`/`$CPP`/`$HIT`…,
|
||||
/// time + payload) — the event-order instrument (decision 0279: whether `$CPP` precedes the
|
||||
/// impact tag decides defense-anim vs flinch on the shared swing record).
|
||||
@@ -892,6 +901,7 @@ fn main() -> Result<()> {
|
||||
}
|
||||
Command::Glueextent { batches } => glueextent::glueextent(&mut chain, batches)?,
|
||||
Command::M2coll { internal_path } => m2dump::m2coll(&mut chain, &internal_path)?,
|
||||
Command::M2cam { internal_path } => m2dump::m2cam(&mut chain, &internal_path)?,
|
||||
Command::M2seq { internal_path } => m2dump::m2seq(&mut chain, &internal_path)?,
|
||||
Command::M2events { internal_path } => m2dump::m2events(&mut chain, &internal_path)?,
|
||||
Command::M2attach { internal_path } => m2dump::m2attach(&mut chain, &internal_path)?,
|
||||
|
||||
@@ -243,21 +243,22 @@ pub use models::{
|
||||
owner_last_rung, owner_last_rung_bucket, parse_m2_animation_lookup, parse_m2_animation_summary,
|
||||
parse_m2_animations, parse_m2_attachments, parse_m2_bounds, parse_m2_camera,
|
||||
parse_m2_cch_marker, parse_m2_collision_hull, parse_m2_event_markers,
|
||||
parse_m2_global_sequence_bones, parse_m2_lights, parse_m2_playable_animation_lookup,
|
||||
parse_m2_portrait_camera, parse_m2_render_submeshes, parse_m2_skeleton,
|
||||
parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights, parse_wmo_portals, parse_wmo_root,
|
||||
rotation_2x2, shipped_glue_art_extent, uv_transform, wmo_group_doodad_refs,
|
||||
wmo_group_fixed_colors, wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs,
|
||||
wmo_group_liquid_mesh, wmo_group_raw_colors, wmo_group_submeshes, wmo_root_id, AlphaAnim,
|
||||
AlphaMap, AlphaSeq, AnimEvent, ArtExtent, BatchFootprint, Billboard, BillboardKind, BoneKeys,
|
||||
BoneScaleAnim, BoneSpin, CharSkinSlot, CollisionMesh, Coverage, CoverageReader,
|
||||
EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone, GlobalSeqChannel,
|
||||
GroundQuad, KeyAnim, M2AnimSummary, M2Attachment, M2Bounds, M2Light, M2PortraitCamera,
|
||||
ModelAnimation, ModelBlend, ParentArm, ParentBasis, PlayableAnim, RenderSubmesh, RgbAnim,
|
||||
ScalarAnim, SeqLoops, Skeleton, SkeletonBone, StringAnchors, UvAnim, UvRotAnim, WmoBatchClass,
|
||||
WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo,
|
||||
WmoPortalRef, WmoPortals, WmoRoot, ALPHA_KEY_REF, DEGENERATE_RING_FOOTPRINT,
|
||||
GLUE_AUTHORED_ASPECT, NO_GROUP_LIQUID, OWNER_RUNG_BUCKETS,
|
||||
parse_m2_global_sequence_bones, parse_m2_lights, parse_m2_pane_cameras,
|
||||
parse_m2_playable_animation_lookup, parse_m2_portrait_camera, parse_m2_render_submeshes,
|
||||
parse_m2_skeleton, parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights,
|
||||
parse_wmo_portals, parse_wmo_root, rotation_2x2, shipped_glue_art_extent, uv_transform,
|
||||
wmo_group_doodad_refs, wmo_group_fixed_colors, wmo_group_footprint_tris, wmo_group_header,
|
||||
wmo_group_light_refs, wmo_group_liquid_mesh, wmo_group_raw_colors, wmo_group_submeshes,
|
||||
wmo_root_id, AlphaAnim, AlphaMap, AlphaSeq, AnimEvent, ArtExtent, BatchFootprint, Billboard,
|
||||
BillboardKind, BoneKeys, BoneScaleAnim, BoneSpin, CharSkinSlot, CollisionMesh, Coverage,
|
||||
CoverageReader, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone,
|
||||
GlobalSeqChannel, GroundQuad, KeyAnim, M2AnimSummary, M2Attachment, M2Bounds, M2CameraTracks,
|
||||
M2Light, M2PaneCamera, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis,
|
||||
PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, Skeleton, SkeletonBone,
|
||||
StringAnchors, UvAnim, UvRotAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog,
|
||||
WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
|
||||
ALPHA_KEY_REF, DEGENERATE_RING_FOOTPRINT, GLUE_AUTHORED_ASPECT, NO_GROUP_LIQUID,
|
||||
OWNER_RUNG_BUCKETS,
|
||||
};
|
||||
mod terrain;
|
||||
pub use terrain::{
|
||||
|
||||
@@ -429,3 +429,125 @@ mod tests {
|
||||
assert_eq!(bone_z_axis(&vec![0u8; 0x400], 0), [0.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
|
||||
/// One record of the file's **camera table**, by RAW table index — what `Model:SetCamera(n)`
|
||||
/// selects on a `<Model>` widget.
|
||||
///
|
||||
/// The selection is **raw**: `0x76cec0` reads the count off `MD20+0x124` and the record at
|
||||
/// `[model+0x3c4] + idx·0x84 + 0x80`, and **`cameraLookup` is not consulted** on that path (wow-re
|
||||
/// `ui/scratch/modelframe-camera-law.md` §2.1 — the sibling pair that *does* consult it,
|
||||
/// `0x713500`/`0x713540`, has its one call site at the portrait bake). So the index decides and
|
||||
/// [`Self::camera_type`] never does; it is carried because a reader will otherwise assume the
|
||||
/// opposite.
|
||||
///
|
||||
/// [`Self::still`] is the rest rig — the record's bases plus each track's first key. That is the
|
||||
/// whole answer for every model a `<Model>` pane can name: wow-re's census over all 9691 `.m2` of
|
||||
/// the composite found every character/creature/interface camera's position/target/roll track
|
||||
/// carrying exactly one key of `(0,0,0)`/`0` (§7). [`Self::at`] is the general case — the
|
||||
/// `Cameras\*.m2` fly-bys, whose Bézier paths a widget would sample like any other track.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct M2PaneCamera {
|
||||
/// The record's `type` word (`+0x00`): `0` = portrait, `1` = "characterinfo", `-1` on every
|
||||
/// shipped fly-by and on the six glue scenes. Never selected on — see the type docs.
|
||||
pub camera_type: i32,
|
||||
/// The rig at rest: bases + each track's first key, raw WoW model space.
|
||||
pub still: M2PortraitCamera,
|
||||
/// The authored tracks, kept **only when one of them actually moves** (more than one distinct
|
||||
/// key) — otherwise `still` is the camera at every instant and there is nothing to sample.
|
||||
pub tracks: Option<Box<M2CameraTracks>>,
|
||||
}
|
||||
|
||||
/// A moving camera's three authored tracks plus their bases — the publish pass's inputs
|
||||
/// (`0x718960` `[0x718b60, 0x718c3a)`): `eye = position_base + positions(t)`,
|
||||
/// `target = target_position_base + target(t)`, `roll = roll(t)`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct M2CameraTracks {
|
||||
pub positions: benilla_m2::M2Vec3SplineTrack,
|
||||
pub position_base: [f32; 3],
|
||||
pub target: benilla_m2::M2Vec3SplineTrack,
|
||||
pub target_base: [f32; 3],
|
||||
pub roll: benilla_m2::M2ScalarSplineTrack,
|
||||
}
|
||||
|
||||
impl M2PaneCamera {
|
||||
/// The rig at absolute file-timeline `ms` — [`Self::still`] for a static camera, the sampled
|
||||
/// tracks for a moving one. Raw WoW model space, radians.
|
||||
///
|
||||
/// The sampler is [`benilla_m2::M2Track::sample_ms`], the reference's own four-way cubic
|
||||
/// `interp` dispatch (STEP/LINEAR/BÉZIER/HERMITE) with both ends clamped — the fly-bys author
|
||||
/// BÉZIER. `ms` is the model's animation clock, which for a widget pane is its private scene
|
||||
/// clock resolved through the armed sequence's band (the pane's play head plus the band start).
|
||||
pub fn at(&self, ms: u32) -> M2PortraitCamera {
|
||||
let Some(t) = self.tracks.as_deref() else {
|
||||
return self.still;
|
||||
};
|
||||
let add = |b: [f32; 3], v: Option<[f32; 3]>| {
|
||||
let v = v.unwrap_or([0.0; 3]);
|
||||
[b[0] + v[0], b[1] + v[1], b[2] + v[2]]
|
||||
};
|
||||
M2PortraitCamera {
|
||||
position: add(t.position_base, t.positions.sample_ms(ms)),
|
||||
target: add(t.target_base, t.target.sample_ms(ms)),
|
||||
roll: t.roll.sample_ms(ms).unwrap_or(self.still.roll),
|
||||
..self.still
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the whole camera table, in file order — the raw index space `Model:SetCamera(n)` walks.
|
||||
/// Empty for the overwhelming majority of models (9225 of the composite's 9691).
|
||||
///
|
||||
/// This is the table read of [`parse_m2_camera`]'s single-record reduction; both go through
|
||||
/// [`benilla_m2::parse_cameras`], which is the one place the `0x7c` record layout is written down.
|
||||
pub fn parse_m2_pane_cameras(bytes: &[u8]) -> Vec<M2PaneCamera> {
|
||||
benilla_m2::parse_cameras(bytes)
|
||||
.into_iter()
|
||||
.map(|cam| {
|
||||
let key0 = |t: &benilla_m2::M2Vec3SplineTrack| {
|
||||
t.keys.first().map_or([0.0; 3], |(_, k)| k.value)
|
||||
};
|
||||
let base_plus = |b: [f32; 3], t: &benilla_m2::M2Vec3SplineTrack| {
|
||||
let k = key0(t);
|
||||
[b[0] + k[0], b[1] + k[1], b[2] + k[2]]
|
||||
};
|
||||
let still = M2PortraitCamera {
|
||||
fov: cam.fov,
|
||||
far_clip: cam.far_clip,
|
||||
near_clip: cam.near_clip,
|
||||
position: base_plus(cam.position_base, &cam.positions),
|
||||
target: base_plus(cam.target_base, &cam.target),
|
||||
roll: cam.roll.keys.first().map_or(0.0, |(_, k)| k.value),
|
||||
};
|
||||
// A track that never leaves its first key is the still rig at every instant; keeping
|
||||
// it would cost a sample per frame to reproduce a constant. Every shipped
|
||||
// character/creature/interface camera lands here (wow-re camera-law §7).
|
||||
let moves = |n: usize, same: bool| n > 1 && !same;
|
||||
let v3_moves = |t: &benilla_m2::M2Vec3SplineTrack| {
|
||||
let first = key0(t);
|
||||
moves(t.keys.len(), t.keys.iter().all(|(_, k)| k.value == first))
|
||||
};
|
||||
let roll_moves = {
|
||||
let first = cam.roll.keys.first().map_or(0.0, |(_, k)| k.value);
|
||||
moves(
|
||||
cam.roll.keys.len(),
|
||||
cam.roll.keys.iter().all(|(_, k)| k.value == first),
|
||||
)
|
||||
};
|
||||
let tracks =
|
||||
(v3_moves(&cam.positions) || v3_moves(&cam.target) || roll_moves).then(|| {
|
||||
Box::new(M2CameraTracks {
|
||||
positions: cam.positions,
|
||||
position_base: cam.position_base,
|
||||
target: cam.target,
|
||||
target_base: cam.target_base,
|
||||
roll: cam.roll,
|
||||
})
|
||||
});
|
||||
M2PaneCamera {
|
||||
camera_type: cam.camera_type,
|
||||
still,
|
||||
tracks,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -56,6 +56,32 @@ impl Loader<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The model pane's own fog attributes and `<FogColor>` child (`CSimpleModel::LoadXML`
|
||||
// `0x76cac0`, render law §5.4). `fogNear`/`fogFar` are **clamped at `≥ 0`** here and only
|
||||
// here (`76cbbb`-`76cbd2` / `76cbf3`-`76cc0a`: `0.0 fcomp value ; jne store ; else store
|
||||
// 0.0`) — the Lua setters store raw. The `<FogColor>` child writes the packed colour AND
|
||||
// arms the fog bit, so it is `SetFogColor` in every respect; nothing in XML touches the
|
||||
// light. Decision 2027.
|
||||
if model_kind {
|
||||
for (attr, verb) in [("fogNear", "SetFogNear"), ("fogFar", "SetFogFar")] {
|
||||
if let Some(v) = el.attr(attr).and_then(|v| v.trim().parse::<f32>().ok()) {
|
||||
self.call(wrapper, verb, v.max(0.0), dbg);
|
||||
}
|
||||
}
|
||||
if let Some(fc) = children_named(el, "FogColor").next() {
|
||||
let ch = |k: &str, d: f32| {
|
||||
fc.attr(k)
|
||||
.and_then(|v| v.trim().parse::<f32>().ok())
|
||||
.unwrap_or(d)
|
||||
};
|
||||
self.call(
|
||||
wrapper,
|
||||
"SetFogColor",
|
||||
(ch("r", 0.0), ch("g", 0.0), ch("b", 0.0), ch("a", 1.0)),
|
||||
dbg,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(id) = el.attr("id") {
|
||||
if let Ok(n) = id.parse::<i64>() {
|
||||
self.call(wrapper, "SetID", n, dbg);
|
||||
|
||||
@@ -127,6 +127,9 @@ impl UiScript {
|
||||
position: m.position,
|
||||
own_alpha: frame.map_or(1.0, |f| f.alpha),
|
||||
icon: m.icon.clone(),
|
||||
camera: m.camera,
|
||||
light: m.light,
|
||||
fog: m.armed_fog(),
|
||||
},
|
||||
_ => QuadContent::Frame,
|
||||
};
|
||||
|
||||
@@ -152,6 +152,9 @@ mod trainer;
|
||||
mod types;
|
||||
mod unit;
|
||||
mod weapon_enchant;
|
||||
/// The `/who` list's seven-key sort chain and its comparator — its header is the whole
|
||||
/// mechanism, including why a repeated header click reverses (decision 2030).
|
||||
mod who_sort;
|
||||
mod worldmap;
|
||||
mod worldstate;
|
||||
mod worn_display;
|
||||
@@ -268,6 +271,7 @@ pub use unit::{
|
||||
grey_band, level_reads_unknown, power_token, unit_is_grey, SelectionRequest, UnitState,
|
||||
};
|
||||
pub use weapon_enchant::WeaponEnchant;
|
||||
pub use who_sort::{WhoSortChain, WhoSortKey};
|
||||
pub use worldmap::{
|
||||
WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapState,
|
||||
WorldMapZoneView,
|
||||
|
||||
@@ -94,7 +94,7 @@ use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
use super::object::frame_handle_of;
|
||||
use super::Model;
|
||||
use crate::widget::{model_key, FrameHandle, KindState, ModelFileFacts, ModelState};
|
||||
use crate::widget::{model_key, FrameHandle, KindState, ModelFileFacts, ModelLight, ModelState};
|
||||
|
||||
impl Model {
|
||||
/// FrameXML units per **layout unit** — `768 · √(a²+1)` for the screen's aspect `a`
|
||||
@@ -232,6 +232,103 @@ fn int(v: &Value) -> i32 {
|
||||
num(v) as i32
|
||||
}
|
||||
|
||||
/// `lua_isnumber` — a number, or a string that converts, and **nothing else**. Distinct from
|
||||
/// [`num`] on purpose: `SetLight` *raises* on a non-number where the coordinate setters coerce,
|
||||
/// so the two questions cannot share one helper.
|
||||
fn number(v: &Value) -> Option<f32> {
|
||||
match v {
|
||||
Value::Number(n) => Some(*n as f32),
|
||||
Value::Integer(i) => Some(*i as f32),
|
||||
Value::String(s) => s.to_str().ok().and_then(|t| t.trim().parse().ok()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `[0x8029d4]` — the client's "is this float zero" epsilon, shared by `SetLight`'s intensity
|
||||
/// gate, `0x71b6a0`'s direction normalise and the paint's degenerate-rect test.
|
||||
const FLOAT_EPS: f32 = 2.384_185_8e-7;
|
||||
|
||||
/// `Model:SetLight`'s usage string (`0x878c00`), which the binding raises verbatim.
|
||||
const SET_LIGHT_USAGE: &str = "Usage: Model:SetLight(enabled[, omni, dirX, dirY, dirZ, \
|
||||
ambIntensity[, ambR, ambG, ambB], dirIntensity[, dirR, dirG, dirB]]";
|
||||
|
||||
/// What [`parse_set_light`] decided.
|
||||
enum SetLight {
|
||||
/// A non-number where the binding requires one — `luaL_error(SET_LIGHT_USAGE)`.
|
||||
Raise,
|
||||
/// **Trap 1.** `enabled == 0` returns at `76e2cb` *before* the local light is copied into the
|
||||
/// widget, so the call writes nothing at all: it neither disables a light nor edits one.
|
||||
NoOp,
|
||||
/// The light to copy wholesale over the widget's (`76e777 0x76cf30`, `rep movsd 0x1b`).
|
||||
Set(ModelLight),
|
||||
}
|
||||
|
||||
/// `Model:SetLight` `0x76e1e0`'s argument walk (render law §5.3), over the arguments **after
|
||||
/// `self`** — so `a[0]` is the reference's Lua index 2.
|
||||
///
|
||||
/// The binding builds a **local** `CGLight` from `0x71b4a0` — type 1, every colour ZERO, which is
|
||||
/// not the widget ctor's white — fills it, and copies the whole thing over the widget's at the
|
||||
/// end. So an omitted colour block lands the *binding's* black, never the ctor's white.
|
||||
///
|
||||
/// **Trap 2** is the walking cursor: the ambient colour triple is read only when its intensity is
|
||||
/// nonzero **and** all three components are numbers; when either fails, the colour stays white
|
||||
/// and the cursor does *not* advance past it — so the second intensity is read at index 8 rather
|
||||
/// than 11, and `SetLight(1, 0, x, y, z, ambI, dirI)` is a legal seven-argument form.
|
||||
fn parse_set_light(a: &[Value]) -> SetLight {
|
||||
let Some(enabled) = a.first().and_then(number) else {
|
||||
return SetLight::Raise;
|
||||
};
|
||||
if enabled as i32 == 0 {
|
||||
return SetLight::NoOp;
|
||||
}
|
||||
// Indices 3..=7 — omni, x, y, z, ambIntensity — are all mandatory once the light is enabled.
|
||||
let mut head = [0.0f32; 5];
|
||||
for (i, slot) in head.iter_mut().enumerate() {
|
||||
match a.get(i + 1).and_then(number) {
|
||||
Some(v) => *slot = v,
|
||||
None => return SetLight::Raise,
|
||||
}
|
||||
}
|
||||
let omni = head[0] as i32 != 0;
|
||||
let mut vector = [head[1], head[2], head[3]];
|
||||
if !omni {
|
||||
// `0x71b6a0` normalises a DIRECTION on write (`71b6c5`-`71b705`); the position setter
|
||||
// `0x71b650` stores raw.
|
||||
let len = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt();
|
||||
if len > FLOAT_EPS {
|
||||
vector = vector.map(|v| v / len);
|
||||
}
|
||||
}
|
||||
// The colour blocks: each packed to 8 bits per channel and unpacked again (`0x76f900`), so a
|
||||
// Set→Get round trip is lossy exactly the way `SetFogColor`'s is.
|
||||
let quantize = |v: f32| ((v.clamp(0.0, 1.0) * 255.0).round()) / 255.0;
|
||||
let block = |at: usize| -> Option<[f32; 3]> {
|
||||
let c: Vec<f32> = (at..at + 3)
|
||||
.filter_map(|k| a.get(k).and_then(number))
|
||||
.collect();
|
||||
(c.len() == 3).then(|| [quantize(c[0]), quantize(c[1]), quantize(c[2])])
|
||||
};
|
||||
let ambient_i = head[4];
|
||||
let (ambient_rgb, next) = match block(6).filter(|_| ambient_i.abs() > FLOAT_EPS) {
|
||||
Some(rgb) => (rgb, 9), // the cursor advanced: Lua index 11
|
||||
None => ([1.0; 3], 6), // white, and the cursor stayed: Lua index 8
|
||||
};
|
||||
let mut light = ModelLight {
|
||||
enabled: true,
|
||||
omni,
|
||||
vector,
|
||||
ambient: ambient_rgb.map(|c| c * ambient_i),
|
||||
diffuse: [0.0; 3],
|
||||
};
|
||||
if let Some(diffuse_i) = a.get(next).and_then(number) {
|
||||
let rgb = block(next + 1)
|
||||
.filter(|_| diffuse_i.abs() > FLOAT_EPS)
|
||||
.unwrap_or([1.0; 3]);
|
||||
light.diffuse = rgb.map(|c| c * diffuse_i);
|
||||
}
|
||||
SetLight::Set(light)
|
||||
}
|
||||
|
||||
/// **Shape A's gate** (decision 1717's taxonomy, for a *string* position): the client's
|
||||
/// `lua_isstring` accepts tags 3|4 — a string **or a number** — and nothing else. A number is
|
||||
/// rendered to decimal text and used as the path, which is why this coerces rather than matching
|
||||
@@ -366,11 +463,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"GetModelScale",
|
||||
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.scale))?,
|
||||
)?;
|
||||
// `SetCamera(n)` = `0x76e0e0` -> `0x76cec0`: select the file's camera by **RAW table index**
|
||||
// (`cameraLookup` is not on this path). With the file's facts in hand the index is resolved
|
||||
// now — an index past the table's count installs the NULL camera, which is the orthographic
|
||||
// leg; without them it is deferred, and the pane draws nothing until it resolves.
|
||||
m.set(
|
||||
"SetCamera",
|
||||
lua.create_function(|lua, (this, c): (Table, Value)| {
|
||||
let c = int(&c);
|
||||
with_model(lua, &this, |m| m.camera = c)
|
||||
let facts = facts_of_pane(lua, &this)?;
|
||||
with_model(lua, &this, |m| m.install_camera(c, facts.as_deref()))
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
@@ -390,9 +492,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
// ── The scene: light and fog ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `SetLight`'s numbers are stored VERBATIM and handed back verbatim. The engine core has no
|
||||
// lighting model, so typing this tuple would be asserting a scene semantics nobody has
|
||||
// verified — and a wrong typing is worse than an opaque one, because it reads as knowledge.
|
||||
// Typed since decision 2027, off the render law's §5.1-§5.4: the widget's embedded `CGLight`
|
||||
// and the four fog fields, with `SetLight`'s argument walk and both of its traps.
|
||||
m.set(
|
||||
"SetLight",
|
||||
lua.create_function(|lua, args: MultiValue| {
|
||||
@@ -401,19 +502,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Some(Value::Table(t)) => t,
|
||||
_ => return Err(mlua::Error::runtime("expected a Model")),
|
||||
};
|
||||
let nums: Vec<f32> = it.map(|v| num(&v)).collect();
|
||||
with_model(lua, &this, |m| m.light = Some(nums))
|
||||
let a: Vec<Value> = it.collect();
|
||||
match parse_set_light(&a) {
|
||||
SetLight::Raise => Err(mlua::Error::runtime(SET_LIGHT_USAGE)),
|
||||
SetLight::NoOp => with_model(lua, &this, |_| ()),
|
||||
SetLight::Set(l) => with_model(lua, &this, |m| m.light = l),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetLight",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let light = with_model(lua, &this, |m| m.light.clone())?;
|
||||
let out = light
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|n| Value::Number(f64::from(n)))
|
||||
.collect::<Vec<_>>();
|
||||
let l = with_model(lua, &this, |m| m.light)?;
|
||||
let mut out: Vec<Value> = vec![
|
||||
Value::Number(f64::from(u8::from(l.enabled))),
|
||||
Value::Number(f64::from(u8::from(l.omni))),
|
||||
Value::Number(f64::from(l.vector[0])),
|
||||
Value::Number(f64::from(l.vector[1])),
|
||||
Value::Number(f64::from(l.vector[2])),
|
||||
];
|
||||
// Each colour block comes back as `1.0, r, g, b` when ANY component is `> 0`, and as
|
||||
// a lone `0` otherwise (`76e91f`/`76e92f`/`76e93f je 0x76e953` -> `push 1.0`) — which
|
||||
// is what makes the arity 7 | 10 | 13 rather than a fixed 13.
|
||||
for c in [l.ambient, l.diffuse] {
|
||||
if c.iter().any(|&v| v > 0.0) {
|
||||
out.push(Value::Number(1.0));
|
||||
out.extend(c.iter().map(|&v| Value::Number(f64::from(v))));
|
||||
} else {
|
||||
out.push(Value::Number(0.0));
|
||||
}
|
||||
}
|
||||
Ok(MultiValue::from_vec(out))
|
||||
})?,
|
||||
)?;
|
||||
@@ -429,7 +547,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32;
|
||||
let a = a.as_ref().map_or(1.0, num);
|
||||
let packed = (q(a) << 24) | (q(num(&r)) << 16) | (q(num(&g)) << 8) | q(num(&b));
|
||||
with_model(lua, &this, |m| m.fog_color = packed)
|
||||
// `76f059 or [edi+0x3a4],1` — setting the colour ARMS the fog. That is the only
|
||||
// arming verb (with the XML `<FogColor>` child); there is no `SetFogEnabled`.
|
||||
with_model(lua, &this, |m| {
|
||||
m.fog_color = packed;
|
||||
m.fog = true;
|
||||
})
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
@@ -444,6 +567,39 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `SetFogNear 0x76f1e0` / `SetFogFar 0x76f390` store the number RAW — no clamp, no ordering
|
||||
// check (`76f282`/`76f432 fstp`); the `≥ 0` clamp exists only on the XML attribute path. The
|
||||
// getters read the same fields back. Nothing here arms the fog.
|
||||
m.set(
|
||||
"SetFogNear",
|
||||
lua.create_function(|lua, (this, v): (Table, Value)| {
|
||||
let v = num(&v);
|
||||
with_model(lua, &this, |m| m.fog_near = v)
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetFogNear",
|
||||
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.fog_near))?,
|
||||
)?;
|
||||
m.set(
|
||||
"SetFogFar",
|
||||
lua.create_function(|lua, (this, v): (Table, Value)| {
|
||||
let v = num(&v);
|
||||
with_model(lua, &this, |m| m.fog_far = v)
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetFogFar",
|
||||
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.fog_far))?,
|
||||
)?;
|
||||
// `ClearFog 0x76f540`: `76f5c5 and [edi+0x3a4],-2` — **bit 0 and nothing else**. The colour,
|
||||
// the near and the far all survive, so a later `SetFogColor` re-arms the same ramp. A clear
|
||||
// that also reset them would read as knowledge and be wrong.
|
||||
m.set(
|
||||
"ClearFog",
|
||||
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.fog = false))?,
|
||||
)?;
|
||||
|
||||
// ── The two verbs that touch no pane state ──────────────────────────────────────────────
|
||||
|
||||
// `AdvanceTime()` — `0x878948[14]` -> `0x76eca0`. **It does nothing, and that is the verified
|
||||
@@ -683,6 +839,13 @@ impl crate::script::UiScript {
|
||||
};
|
||||
let path = m.path.as_deref()?;
|
||||
let facts = model.model_facts.get(&model_key(path))?;
|
||||
// The reference's draw gate (`76d5f0 cmp [this+0x320],-1 ; jne`): a pane whose
|
||||
// camera question is still open paints NOTHING — not the model, not its
|
||||
// `OnUpdateModel`. Reachable from Lua by `SetCamera(n)` on a pane whose file has
|
||||
// not landed yet (decision 2027).
|
||||
if m.camera_pending.is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(ModelPaneFrame {
|
||||
handle: h,
|
||||
clock_ms: m.clock_ms,
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
|
||||
use mlua::{Lua, Value};
|
||||
|
||||
use super::binding_abi::string_arg;
|
||||
use super::who_sort::WhoSortChain;
|
||||
use super::Model;
|
||||
|
||||
/// One friend row, already resolved for display — see the module doc on why the app resolves
|
||||
@@ -78,6 +80,13 @@ pub struct SocialState {
|
||||
/// The last `/who` answer's *total* match count — `GetNumWhoResults`'s second return, which
|
||||
/// can exceed `who.len()` and is what drives the "(50 displayed)" suffix.
|
||||
pub who_total: u32,
|
||||
/// The `/who` sort chain ([`WhoSortChain`]) as the app holds it. Pushed with the rows because
|
||||
/// `SortWho` has to promote it and re-sort **inside the binding** — the reference's
|
||||
/// `WHO_LIST_UPDATE` is synchronous, so the redraw it triggers reads the new order before the
|
||||
/// click script returns, a tick before the app's own copy could have pushed it back.
|
||||
/// [`super::SocialRequest::SortWho`] carries the same click to the app, whose next push then
|
||||
/// agrees. Same shape as `SetSelectedFriend`'s (module doc).
|
||||
pub who_sort: WhoSortChain,
|
||||
}
|
||||
|
||||
/// Outbound social intents queued by the Era API, drained by the app
|
||||
@@ -111,8 +120,10 @@ pub enum SocialRequest {
|
||||
/// `SendWho(filter)` — the raw filter string as typed; parsing it into wire fields needs the
|
||||
/// DBCs, so it happens app-side.
|
||||
Who(String),
|
||||
/// `SortWho(sortType)` — `"name"`/`"level"`/`"class"`/`"zone"`/`"guild"`/`"race"`. Sorting
|
||||
/// is client-side; the app re-orders its own results and pushes them back.
|
||||
/// `SortWho(sortType)` — `"name"`/`"level"`/`"class"`/`"zone"`/`"guild"`/`"race"`, the raw
|
||||
/// argument as the click passed it. Sorting is client-side and the binding has **already**
|
||||
/// done it to the snapshot ([`SocialState::who_sort`]); this carries the same click to the
|
||||
/// app so its authoritative chain promotes identically and the next push agrees.
|
||||
SortWho(String),
|
||||
/// `SetWhoToUI(flag)` — where the *next* `/who` answer goes: the Who frame (true) or the chat
|
||||
/// frame (false). The WhoFrame's own OnShow/OnHide drive it.
|
||||
@@ -476,14 +487,38 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SortWho(sortType) — the column-header and dropdown sorts.
|
||||
// `SortWho(sortType) 0x5ad890` — the column-header and dropdown sorts, and three things at
|
||||
// once (wow-re `who-list-sort-law.md`; decision 2030):
|
||||
//
|
||||
// 1. **promote** the key into the seven-slot chain, flipping its direction only if it was
|
||||
// already at the front — so a repeated click on the same header REVERSES;
|
||||
// 2. **sort right here**, through the chain-walking comparator; and
|
||||
// 3. fire `WHO_LIST_UPDATE` **synchronously**, inside the binding (`0x5ad9ed
|
||||
// mov ecx,0x184; call SignalEvent 0x703e50`), so `FriendsFrame_OnEvent` has already
|
||||
// re-read the list through `GetWhoInfo` by the time the header's OnClick plays its sound.
|
||||
// Queueing it would redraw a tick late — the visible half of B365.
|
||||
//
|
||||
// The intent still goes to the app, which owns the same chain and re-sorts the answers it
|
||||
// receives; sorting the snapshot here is what makes the synchronous redraw show the new
|
||||
// order, exactly as `SetSelectedFriend` mutates the snapshot it also queues (module doc).
|
||||
//
|
||||
// Zero return values, and a non-string/number argument RAISES with the client's own typo
|
||||
// (`.rdata 0x85db88`) rather than answering nil — `0x5ad898`'s `0x6f3510` guard into
|
||||
// `luaL_error`, [`super::binding_abi`]'s shape A.
|
||||
g.set(
|
||||
"SortWho",
|
||||
lua.create_function(|lua, sort_type: String| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model
|
||||
.social_requests
|
||||
.push(SocialRequest::SortWho(sort_type));
|
||||
lua.create_function(|lua, sort_type: Value| {
|
||||
let sort_type = string_arg(lua, sort_type, "Usgae: SortWho(\"type\")")?;
|
||||
{
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let model = &mut *model;
|
||||
model.social.who_sort.promote(&sort_type);
|
||||
model.social.who_sort.sort(&mut model.social.who);
|
||||
model
|
||||
.social_requests
|
||||
.push(SocialRequest::SortWho(sort_type));
|
||||
}
|
||||
super::tick::fire_event_into(lua, "WHO_LIST_UPDATE", Vec::new());
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -525,9 +560,143 @@ fn clamp_index(index: i64, len: usize) -> u32 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SocialRequest;
|
||||
use super::{SocialRequest, SocialState, WhoInfo};
|
||||
use crate::script::UiScript;
|
||||
|
||||
fn who(name: &str, level: u32, zone: &str) -> WhoInfo {
|
||||
WhoInfo {
|
||||
name: name.to_string(),
|
||||
guild: String::new(),
|
||||
level,
|
||||
race: "Human".to_string(),
|
||||
class: "Warrior".to_string(),
|
||||
zone: zone.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A VM seeded with two hits and a watcher that records the order it can SEE from inside the
|
||||
/// `WHO_LIST_UPDATE` handler — which is the only vantage point that can tell a synchronous
|
||||
/// fire from a queued one.
|
||||
fn seated() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
fired = 0
|
||||
order = ""
|
||||
local f = CreateFrame("Frame", "WhoWatcher")
|
||||
f:RegisterEvent("WHO_LIST_UPDATE")
|
||||
f:SetScript("OnEvent", function()
|
||||
fired = fired + 1
|
||||
order = ""
|
||||
for i = 1, GetNumWhoResults() do
|
||||
order = order .. GetWhoInfo(i) .. ","
|
||||
end
|
||||
end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.set_social(SocialState {
|
||||
who: vec![
|
||||
who("Galas", 60, "Elwynn Forest"),
|
||||
who("Erdrin", 12, "Elwynn Forest"),
|
||||
],
|
||||
who_total: 2,
|
||||
..Default::default()
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
/// **B365.** `SortWho` sorts the list it already holds, fires `WHO_LIST_UPDATE`
|
||||
/// **synchronously** so the handler reads the NEW order (`0x5ad9ed`, `SignalEvent 0x703e50`
|
||||
/// running every listener inline), and queues the same click for the app. A queued event
|
||||
/// would leave `order` one click stale here — which is exactly what the bug looked like.
|
||||
#[test]
|
||||
fn sort_who_sorts_in_place_and_fires_the_event_synchronously() {
|
||||
let mut s = seated();
|
||||
assert_eq!(s.eval::<i64>("return fired").unwrap(), 0);
|
||||
|
||||
s.run(r#"SortWho("name")"#).unwrap();
|
||||
assert_eq!(s.eval::<i64>("return fired").unwrap(), 1);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return order").unwrap(),
|
||||
"Erdrin,Galas,",
|
||||
"the handler must already see the sorted list"
|
||||
);
|
||||
assert_eq!(
|
||||
s.take_social_requests(),
|
||||
vec![SocialRequest::SortWho("name".into())],
|
||||
"and the app hears the same click, so its copy of the chain follows"
|
||||
);
|
||||
// Zero return values (`0x5ad9f8 xor eax,eax; ret`).
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return select('#', SortWho("name"))"#)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The visible half of the report: **clicking the same header twice reverses**, and clicking
|
||||
/// a different one in between does not undo that — the direction is remembered per key and
|
||||
/// flipped only when the key was already at the front of the chain.
|
||||
#[test]
|
||||
fn a_repeated_header_click_reverses_and_the_direction_is_remembered() {
|
||||
let s = seated();
|
||||
s.run(r#"SortWho("name")"#).unwrap();
|
||||
assert_eq!(s.eval::<String>("return order").unwrap(), "Erdrin,Galas,");
|
||||
|
||||
s.run(r#"SortWho("name")"#).unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return order").unwrap(),
|
||||
"Galas,Erdrin,",
|
||||
"the second click on the same key reverses it"
|
||||
);
|
||||
|
||||
// Level ascending puts Erdrin (12) first; name is still descending behind it.
|
||||
s.run(r#"SortWho("level")"#).unwrap();
|
||||
assert_eq!(s.eval::<String>("return order").unwrap(), "Erdrin,Galas,");
|
||||
|
||||
// Back to name: promoted from slot 1, so it CARRIES its descending direction rather than
|
||||
// flipping to ascending.
|
||||
s.run(r#"SortWho("name")"#).unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return order").unwrap(),
|
||||
"Galas,Erdrin,",
|
||||
"a key promoted from behind keeps the direction it was left in"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return fired").unwrap(),
|
||||
4,
|
||||
"one fire per click"
|
||||
);
|
||||
}
|
||||
|
||||
/// The argument ABI: a number is stringified and falls through to the name key; anything that
|
||||
/// is neither number nor string RAISES with the client's own misspelt usage string
|
||||
/// (`.rdata 0x85db88`), abandoning the caller's statement rather than answering nil.
|
||||
#[test]
|
||||
fn sort_who_takes_the_reference_argument_abi() {
|
||||
let mut s = seated();
|
||||
s.run("SortWho(5)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return order").unwrap(),
|
||||
"Erdrin,Galas,",
|
||||
"an unrecognised key is the name key"
|
||||
);
|
||||
let _ = s.take_social_requests();
|
||||
|
||||
let err = s.run("SortWho({})").unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains(r#"Usgae: SortWho("type")"#),
|
||||
"the reference's own typo, verbatim: {err}"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return fired").unwrap(),
|
||||
1,
|
||||
"a raised call sorts nothing and fires nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// The LFG pair as the bytes define it (1961, correcting 1959): four string-or-nil returns,
|
||||
/// the slots zeroed by the reference's own pack, the comment from argument 7 behind the gate
|
||||
/// on argument 4, and the wire only on a change.
|
||||
|
||||
@@ -28,6 +28,7 @@ fn facts(rows: &[(u16, u32, bool)]) -> ModelFileFacts {
|
||||
})
|
||||
.collect(),
|
||||
bbox: ([0.0; 3], [0.0; 3]),
|
||||
cameras: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +47,7 @@ fn ping_facts() -> ModelFileFacts {
|
||||
}
|
||||
|
||||
/// The pane's scene state, read through the arena (1.12 has no getter for any of it).
|
||||
fn pane(s: &UiScript, name: &str) -> ModelState {
|
||||
pub(super) fn pane(s: &UiScript, name: &str) -> ModelState {
|
||||
let model = s.lua().app_data_ref::<Model>().expect("model");
|
||||
let fh = model.arena.lookup(name).expect("pane frame");
|
||||
match &model.arena.frame(fh).expect("live frame").kind_state {
|
||||
@@ -427,6 +428,7 @@ fn a_size_less_pane_takes_its_files_rect_in_layout_units() {
|
||||
looping: true,
|
||||
}],
|
||||
bbox: ([-0.0127, -0.0118, 0.0], [0.0135, 0.0145, 0.0]),
|
||||
cameras: 0,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -274,19 +274,13 @@ fn the_two_model_tables_are_the_references_own() {
|
||||
];
|
||||
/// `PlayerModel` — `CGCharacterModelBase`, table `0x84f1fc`, 3 entries, in table order.
|
||||
const PLAYERMODEL_3: [&str; 3] = ["SetUnit", "RefreshUnit", "SetRotation"];
|
||||
/// The subset of [`MODEL_23`] this client does not build. Named, not stubbed (1134 §4) — their
|
||||
/// bodies are uncarved and they have no corpus caller. Moving a name OUT of here means the
|
||||
/// verb was actually implemented — as `AdvanceTime` and `ReplaceIconTexture` were, once
|
||||
/// wow-re carved them (`ui/scratch/model-advancetime-replaceicontexture.md`). The five left
|
||||
/// are the fog near/far set, whose `ClearFog` semantics over the colour/near/far triple nobody
|
||||
/// has read; guessing that clear would read as knowledge.
|
||||
const UNBUILT: [&str; 5] = [
|
||||
"SetFogNear",
|
||||
"GetFogNear",
|
||||
"SetFogFar",
|
||||
"GetFogFar",
|
||||
"ClearFog",
|
||||
];
|
||||
/// The subset of [`MODEL_23`] this client does not build. Named, not stubbed (1134 §4).
|
||||
/// **Empty since decision 2027** — the last five were the fog near/far set, held back only
|
||||
/// because `ClearFog`'s effect on the colour/near/far triple was uncarved; the render law
|
||||
/// (§5.4) reads it as `76f5c5 and [edi+0x3a4],-2`, bit 0 alone, so the guess is gone and the
|
||||
/// verbs are real. Kept as an empty array rather than deleted: the wall below is the thing
|
||||
/// that notices when a name arrives or leaves.
|
||||
const UNBUILT: [&str; 0] = [];
|
||||
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
@@ -385,34 +379,75 @@ fn the_two_sequence_verbs_arm_the_pane_on_its_own_clock() {
|
||||
assert_eq!(armed(&s).1, None);
|
||||
}
|
||||
|
||||
/// `SetLight`'s numbers are stored and returned **verbatim**, however many there are.
|
||||
/// `SetLight` writes the widget's embedded `CGLight` by the reference's own argument walk
|
||||
/// (`0x76e1e0`, render law §5.3), and `GetLight` reads it back at arity **7 | 10 | 13**.
|
||||
///
|
||||
/// The engine core has no lighting model; typing this tuple would assert a scene semantics nobody
|
||||
/// has verified, and a wrong typing is worse than an opaque one because it reads as knowledge. So
|
||||
/// the contract is exactly "what went in comes out".
|
||||
/// This test is the law, not a round trip: the tuple used to be stored verbatim because nobody
|
||||
/// had carved the binding, and "what went in comes out" is the one thing the reference does NOT
|
||||
/// do — the intensities are folded into the colours, the direction is normalised, and both of
|
||||
/// §5.3's traps change what a call means.
|
||||
#[test]
|
||||
fn the_light_tuple_is_opaque_and_survives_the_round_trip() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
m = CreateFrame("Model", "MLight", UIParent)
|
||||
m:SetLight(1, 0, 0, -1, -1, 0.7, 1, 1, 1, 0.8, 1, 1, 1)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.run(r#"m = CreateFrame("Model", "MLight", UIParent)"#)
|
||||
.unwrap();
|
||||
|
||||
// A fresh pane: the ctor's white, switched off. Both colour blocks are `> 0`, so both come
|
||||
// back as `1.0, r, g, b` — arity 13 before anything has ever been set.
|
||||
let fresh: Vec<f64> = s.eval("return { MLight:GetLight() }").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<usize>("return table.getn({ MLight:GetLight() })")
|
||||
.unwrap(),
|
||||
13,
|
||||
"every number handed to SetLight comes back"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>("local t = { MLight:GetLight() } return t[1], t[6]")
|
||||
.unwrap(),
|
||||
(1.0, 0.7_f32 as f64)
|
||||
fresh,
|
||||
vec![0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
"the <Model> ctor: disabled, omni, at the origin, white ambient and diffuse"
|
||||
);
|
||||
|
||||
// The commented-out GlueXML line, which is the corpus's only example of the shape.
|
||||
s.run("MLight:SetLight(1, 0, 0, -0.707, -0.707, 0.7, 1, 1, 1, 0.8, 1, 1, 0.8)")
|
||||
.unwrap();
|
||||
let l: Vec<f64> = s.eval("return { MLight:GetLight() }").unwrap();
|
||||
assert_eq!(l.len(), 13);
|
||||
assert_eq!((l[0], l[1]), (1.0, 0.0), "enabled, directional");
|
||||
// The direction is NORMALISED on write (`0x71b6a0`), so `(0, -0.707, -0.707)` comes back as
|
||||
// a unit vector — the one place a naive round trip would disagree.
|
||||
let dir = [l[2], l[3], l[4]];
|
||||
let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
|
||||
assert!((len - 1.0).abs() < 1e-5, "normalised on write: {dir:?}");
|
||||
// The intensity is FOLDED into the colour: ambient `1,1,1` at `0.7` is `(0.7, 0.7, 0.7)`,
|
||||
// and the reported intensity is a constant 1.0.
|
||||
assert_eq!(l[5], 1.0);
|
||||
for c in &l[6..9] {
|
||||
assert!((c - 0.7).abs() < 1e-5, "ambient folded: {c}");
|
||||
}
|
||||
assert_eq!(l[9], 1.0);
|
||||
for (c, want) in l[10..13].iter().zip([0.8, 0.8, 0.64]) {
|
||||
assert!((c - want).abs() < 1e-5, "diffuse folded: {c} vs {want}");
|
||||
}
|
||||
|
||||
// TRAP 1: `SetLight(0, …)` returns before the local is copied, so it changes NOTHING — it is
|
||||
// not a way to switch a light off, and a `<PlayerModel>`'s built-in one cannot be killed.
|
||||
s.run("MLight:SetLight(0, 1, 5, 5, 5, 1, 1, 1, 1, 1, 1, 1, 1)")
|
||||
.unwrap();
|
||||
let after: Vec<f64> = s.eval("return { MLight:GetLight() }").unwrap();
|
||||
assert_eq!(after, l, "SetLight(0, …) is a no-op, not a disable");
|
||||
|
||||
// TRAP 2: an ambient intensity of 0 makes the walk skip its colour triple WITHOUT advancing
|
||||
// the cursor, so the second intensity is read at index 8 — the seven-argument form. Here
|
||||
// `1, 1, 1` are the ambient rgb the walk never reaches, and `0.5` is index 8 = the diffuse
|
||||
// intensity against a white colour.
|
||||
s.run("MLight:SetLight(1, 1, 0, 0, 0, 0, 0.5)").unwrap();
|
||||
let t2: Vec<f64> = s.eval("return { MLight:GetLight() }").unwrap();
|
||||
assert_eq!(t2.len(), 10, "a zero ambient block collapses the arity");
|
||||
assert_eq!(t2[5], 0.0, "ambient (0,0,0) reports as a lone 0");
|
||||
assert_eq!(t2[6], 1.0);
|
||||
for c in &t2[7..10] {
|
||||
assert!((c - 0.5).abs() < 1e-5, "diffuse read at index 8: {c}");
|
||||
}
|
||||
|
||||
// A non-number where the binding requires one raises rather than coercing.
|
||||
assert!(s.run(r#"MLight:SetLight(1, 0, 0, 0, 0)"#).is_err());
|
||||
assert!(s.run(r#"MLight:SetLight("x")"#).is_err());
|
||||
|
||||
// **Fog is NOT the same shape, and this used to assert the opposite.** It read "unset fog
|
||||
// returns NOTHING rather than three zeros — a pane with no fog and a pane fogged to black are
|
||||
// different states", which is a good argument about a model the client does not have: there is
|
||||
@@ -451,3 +486,126 @@ fn the_light_tuple_is_opaque_and_survives_the_round_trip() {
|
||||
0.0
|
||||
);
|
||||
}
|
||||
|
||||
/// `SetCamera(n)` selects by **raw table index**, is bounds-checked against the file's camera
|
||||
/// count, defers until the file's facts land, and gates the paint until the question is settled —
|
||||
/// the four halves of `0x76cec0`/`0x76ce80`/`0x76ce00`/`76d5f0` (decision 2027).
|
||||
///
|
||||
/// The whole point of the state is which render leg a pane takes: an installed camera is the
|
||||
/// perspective leg, the NULL camera is the orthographic one, and an index past the count is a
|
||||
/// NULL camera rather than an error or a clamp to the last record.
|
||||
#[test]
|
||||
fn set_camera_is_a_raw_index_bounds_checked_against_the_file() {
|
||||
use super::model_clock::pane;
|
||||
use crate::widget::{ModelFileFacts, SequenceFacts};
|
||||
|
||||
let facts = |cameras: u32| ModelFileFacts {
|
||||
sequences: vec![SequenceFacts {
|
||||
anim_id: 0,
|
||||
duration_ms: 1000,
|
||||
looping: true,
|
||||
}],
|
||||
bbox: ([0.0; 3], [0.0; 3]),
|
||||
cameras,
|
||||
};
|
||||
let state = |s: &UiScript, name: &str| {
|
||||
let m = pane(s, name);
|
||||
(m.camera_pending, m.camera)
|
||||
};
|
||||
const FILE: &str = r"Creature\Wolf\Wolf.mdx";
|
||||
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(r#"m = CreateFrame("Model", "MCam", UIParent) m:SetModel("Creature\\Wolf\\Wolf.mdx")"#)
|
||||
.unwrap();
|
||||
// The ctor's standing request for camera 0 (`0x76c910` writes `+0x320 = 0`), still pending
|
||||
// because the file's facts have not arrived — so the pane is NOT on the paint list.
|
||||
assert_eq!(state(&s, "MCam"), (Some(0), None));
|
||||
assert!(s.visible_model_panes().is_empty(), "the draw gate holds");
|
||||
|
||||
// The facts land: the model-ready hook applies the pending index. Wolf has two cameras, so
|
||||
// index 0 installs and the pane takes the perspective leg.
|
||||
s.set_model_facts(FILE, facts(2));
|
||||
assert_eq!(state(&s, "MCam"), (None, Some(0)));
|
||||
assert_eq!(s.visible_model_panes().len(), 1, "the gate is settled");
|
||||
|
||||
// With the facts in hand `SetCamera` resolves immediately — raw index 1 is a real record.
|
||||
s.run("MCam:SetCamera(1)").unwrap();
|
||||
assert_eq!(state(&s, "MCam"), (None, Some(1)));
|
||||
|
||||
// An index past the count installs the NULL camera: the ORTHOGRAPHIC leg, and the pane keeps
|
||||
// drawing. Not a clamp to the last record, not a raise.
|
||||
s.run("MCam:SetCamera(7)").unwrap();
|
||||
assert_eq!(state(&s, "MCam"), (None, None));
|
||||
assert_eq!(s.visible_model_panes().len(), 1);
|
||||
// A negative index lands in the same place (the reference compares unsigned).
|
||||
s.run("MCam:SetCamera(1) MCam:SetCamera(-1)").unwrap();
|
||||
assert_eq!(state(&s, "MCam"), (None, None));
|
||||
|
||||
// A file with no camera table at all: every index is out of range, so a plain <Model> on it
|
||||
// is always the orthographic leg — which is every shipped in-game UI M2.
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(r#"m = CreateFrame("Model", "MFlat", UIParent) m:SetModel("Interface\\Cooldown\\UI-Cooldown-Indicator.mdx")"#)
|
||||
.unwrap();
|
||||
s.set_model_facts(r"Interface\Cooldown\UI-Cooldown-Indicator.mdx", facts(0));
|
||||
assert_eq!(state(&s, "MFlat"), (None, None));
|
||||
|
||||
// `SetCamera` before the file is known DEFERS, and the deferred index is what the facts
|
||||
// apply — bounds-checked then, not at the call.
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(r#"m = CreateFrame("Model", "MLate", UIParent) m:SetCamera(1) m:SetModel("Creature\\Wolf\\Wolf.mdx")"#)
|
||||
.unwrap();
|
||||
assert_eq!(state(&s, "MLate"), (Some(1), None));
|
||||
assert!(s.visible_model_panes().is_empty());
|
||||
s.set_model_facts(FILE, facts(2));
|
||||
assert_eq!(state(&s, "MLate"), (None, Some(1)));
|
||||
}
|
||||
|
||||
/// The fog block: `SetFogColor` **arms** it, `ClearFog` disarms **bit 0 alone**, and near/far are
|
||||
/// stored raw by the Lua setters (render law §5.4, decision 2027). The five verbs held back as
|
||||
/// uncarved since 1134 §4 are real now.
|
||||
#[test]
|
||||
fn the_fog_block_arms_on_colour_and_clears_only_its_bit() {
|
||||
use super::model_clock::pane;
|
||||
|
||||
let fog = |s: &UiScript| {
|
||||
let m = pane(s, "MFog");
|
||||
(m.fog, m.fog_near, m.fog_far)
|
||||
};
|
||||
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(r#"m = CreateFrame("Model", "MFog", UIParent)"#)
|
||||
.unwrap();
|
||||
// The ctor: fog OFF, near 0.0, far **1.0** (`76c950`) — not far 0, which would be a
|
||||
// degenerate ramp the batch would refuse.
|
||||
assert_eq!(fog(&s), (false, 0.0, 1.0));
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>("return MFog:GetFogNear(), MFog:GetFogFar()")
|
||||
.unwrap(),
|
||||
(0.0, 1.0)
|
||||
);
|
||||
|
||||
// The glue's own call shape. `SetFogColor` is the arming verb; near/far are not.
|
||||
s.run("MFog:SetFogNear(0) MFog:SetFogFar(153)").unwrap();
|
||||
assert_eq!(fog(&s), (false, 0.0, 153.0), "near/far do not arm the fog");
|
||||
s.run("MFog:SetFogColor(1.0, 0.61, 0.42)").unwrap();
|
||||
assert!(fog(&s).0, "the colour arms it");
|
||||
|
||||
// `ClearFog` touches bit 0 and nothing else: the colour, the near and the far all survive, so
|
||||
// a later SetFogColor re-arms the same ramp.
|
||||
s.run("MFog:ClearFog()").unwrap();
|
||||
assert_eq!(fog(&s), (false, 0.0, 153.0));
|
||||
let (r, g, b, _): (f64, f64, f64, f64) = s.eval("return MFog:GetFogColor()").unwrap();
|
||||
assert!(
|
||||
(r - 1.0).abs() < 0.01 && (g - 0.61).abs() < 0.01 && (b - 0.42).abs() < 0.01,
|
||||
"the colour survives a clear: {r}, {g}, {b}"
|
||||
);
|
||||
|
||||
// The Lua setters store RAW — no clamp, no ordering check. Only the XML attribute path
|
||||
// clamps at `>= 0`.
|
||||
s.run("MFog:SetFogNear(-40) MFog:SetFogFar(-1)").unwrap();
|
||||
assert_eq!(fog(&s), (false, -40.0, -1.0));
|
||||
}
|
||||
|
||||
@@ -683,8 +683,11 @@ fn cell(model: &Model, rh: crate::widget::RegionHandle) -> Cell {
|
||||
/// for each live GameTooltip, frame size = max measured line width (a double line is
|
||||
/// left + gap + right) + 2·pad, floored by `SetMinimumWidth`, × summed line heights + gaps; each
|
||||
/// visible right column's anchor re-points so its right edge sits at the text inset. Skips
|
||||
/// frames whose lines haven't been measured yet (the XML default size holds until the measure
|
||||
/// round-trip lands, one frame later — the same convergence the Lua loop had).
|
||||
/// frames whose lines haven't been measured yet — the XML default size holds until the extents
|
||||
/// land. With a [`TextMeasure`](super::TextMeasure) installed that is the SAME `resolve`
|
||||
/// (`resolve` solves, fills the pending measures inline, and solves again, so this pre-pass runs
|
||||
/// a second time with real extents); without one it is the host's batch round-trip, one frame
|
||||
/// later, which is the engine-less path every measurer-free VM still takes.
|
||||
pub(super) fn layout_tooltips(model: &mut Model) {
|
||||
// The arena's tooltip registry, not the resolve's whole frame roster: this pre-pass runs at
|
||||
// the top of EVERY resolve, and finding two or three tooltips by scanning ~4000 ids was most
|
||||
@@ -715,8 +718,9 @@ pub(super) fn layout_tooltips(model: &mut Model) {
|
||||
// already the wrapped one; no second round-trip.)
|
||||
// Per-line metrics next (one read pass), then the size + right-flush writes. Only
|
||||
// measured cells contribute — a row with no measured cell adds no height and no gap, and
|
||||
// a tooltip with nothing measured yet is skipped whole (its declared size holds until
|
||||
// the measure round-trip lands, one frame later).
|
||||
// a tooltip with nothing measured yet is skipped whole (its declared size holds until the
|
||||
// extents land — this resolve's own `fill_measures` with a measurer installed, the host's
|
||||
// batch round-trip a frame later without one).
|
||||
let n = num.min(lefts.len()).min(rights.len());
|
||||
let rows: Vec<(Cell, Cell)> = (0..n)
|
||||
.map(|i| (cell(model, lefts[i]), cell(model, rights[i])))
|
||||
|
||||
@@ -130,6 +130,18 @@ pub enum QuadContent {
|
||||
/// `ReplaceIconTexture`'s path — the type-14 texture override, or `None` for the file's
|
||||
/// own textures.
|
||||
icon: Option<String>,
|
||||
/// The installed camera as a RAW index into the file's camera table, or `None` for the
|
||||
/// NULL camera. **This is the render leg** (decision 2027): `None` is the orthographic
|
||||
/// one — the model laid flat over the pane's rect — and `Some(n)` the perspective one,
|
||||
/// framed by the file's own record `n`.
|
||||
camera: Option<u32>,
|
||||
/// The pane's embedded `CGLight`. Disabled on every shipped pane, which is why an
|
||||
/// unlit-on-every-material UI M2 never notices; a LIT batch under a disabled light draws
|
||||
/// black, and that is the reference's answer, not a gap.
|
||||
light: crate::widget::ModelLight,
|
||||
/// The pane's fog, only when armed — colour, near and far as the fill callback stages
|
||||
/// them. Per-batch from there: a material with the UNFOGGED bit ignores it.
|
||||
fog: Option<crate::widget::ModelFog>,
|
||||
},
|
||||
/// A `Texture` region: a BLP path *or* a solid/vertex color (or both — a tinted texture).
|
||||
Texture {
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
//! The `/who` list's sort — the reference's seven-slot key chain, its promote-and-flip rule, and
|
||||
//! the comparator that walks it (`SortWho 0x5ad890`, comparator `0x5ada00`; wow-re
|
||||
//! `system/net/scratch/who-list-sort-law.md`, §5-crossed 2026-09-05, benilla decision 2030).
|
||||
//!
|
||||
//! **A `/who` sort is not "order by the clicked column".** The reference keeps a *chain* of seven
|
||||
//! `{key, dir}` slots at `0xc2817c`/`0xc28180`, seeded once per process to `key[i] = i`
|
||||
//! (`0x5add04`), i.e. **zone → level → class → group → name → race → guild**, every direction
|
||||
//! ascending. Each click *promotes* its key to the front of that chain; the keys it displaces
|
||||
//! stay behind it, so the chain reads most-recently-used first and the comparator walks it as a
|
||||
//! **tie-breaker list**, not as a single key. Two players on the same level keep whatever order
|
||||
//! the previously clicked key gave them.
|
||||
//!
|
||||
//! Two consequences that are the whole of bug B365:
|
||||
//!
|
||||
//! - **A repeated click reverses.** The promote flips the direction **only when the key was
|
||||
//! already at the front** (`0x5ad99e`–`0x5ad9a7`, the `sete` on `i == 0`); promoting a key from
|
||||
//! anywhere else carries its *remembered* direction over unchanged. So Name, Name reverses; but
|
||||
//! Name, Level, Name does **not** — the second Name click restores the direction the first one
|
||||
//! left there.
|
||||
//! - **Nothing here is a request to the server.** The chain and the comparator are engine state;
|
||||
//! `SortWho` sorts the array it already holds and fires `WHO_LIST_UPDATE` synchronously, inside
|
||||
//! the binding ([`super::social`]'s `SortWho`).
|
||||
//!
|
||||
//! The chain is **per-process** in the reference — its initialiser `0x5adc50` runs once from the
|
||||
//! process-start run at `0x401666` — so it survives a logout and the next login inherits it.
|
||||
//! `SocialState::clear_session` on the app side keeps it for that reason.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use super::social::WhoInfo;
|
||||
|
||||
/// The seven keys `SortWho` maps its argument to. **The declaration order is the reference's key
|
||||
/// numbering** (`edi` in `0x5ad8bb`–`0x5ad979`) *and* the chain's seeded order, because the
|
||||
/// initialiser writes `key[i] = i`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum WhoSortKey {
|
||||
/// `0` — `"zone"`, the frame's own resting column.
|
||||
Zone,
|
||||
/// `1` — `"level"`, the one numeric arm: at `dir = 0` the **lower** level comes first.
|
||||
Level,
|
||||
/// `2` — `"class"`.
|
||||
Class,
|
||||
/// `3` — `"group"`. A real chain key whose comparator arm (`0x5adbb2`) *is* the continue
|
||||
/// label: it orders nothing, ever. Unreachable from the shipped UI as well — the header that
|
||||
/// carries `sortType = "group"` sits inside `FriendsFrame.xml`'s commented span 1413–1429 —
|
||||
/// but it is modelled because it occupies a slot, and promoting it still displaces whatever
|
||||
/// key was at the front.
|
||||
Group,
|
||||
/// `4` — `"name"`, and the answer for **any** string the seven compares miss: `edi` is
|
||||
/// preloaded with 4 at `0x5ad8bb` and never reassigned on the no-match path.
|
||||
#[default]
|
||||
Name,
|
||||
/// `5` — `"race"`.
|
||||
Race,
|
||||
/// `6` — `"guild"`.
|
||||
Guild,
|
||||
}
|
||||
|
||||
impl WhoSortKey {
|
||||
/// Every key, in the chain's seeded order.
|
||||
const ALL: [Self; 7] = [
|
||||
Self::Zone,
|
||||
Self::Level,
|
||||
Self::Class,
|
||||
Self::Group,
|
||||
Self::Name,
|
||||
Self::Race,
|
||||
Self::Guild,
|
||||
];
|
||||
|
||||
/// `SortWho`'s argument → key: seven `0x64a4c0` compares (`0x5ad8c0`–`0x5ad979`), which are
|
||||
/// **case-insensitive** (`0x414310` folds `A`–`Z` on both operands), with [`Self::Name`] as
|
||||
/// the no-match answer.
|
||||
pub fn from_sort_type(sort_type: &str) -> Self {
|
||||
match () {
|
||||
_ if sort_type.eq_ignore_ascii_case("name") => Self::Name,
|
||||
_ if sort_type.eq_ignore_ascii_case("level") => Self::Level,
|
||||
_ if sort_type.eq_ignore_ascii_case("class") => Self::Class,
|
||||
_ if sort_type.eq_ignore_ascii_case("group") => Self::Group,
|
||||
_ if sort_type.eq_ignore_ascii_case("race") => Self::Race,
|
||||
_ if sort_type.eq_ignore_ascii_case("zone") => Self::Zone,
|
||||
_ if sort_type.eq_ignore_ascii_case("guild") => Self::Guild,
|
||||
_ => Self::Name,
|
||||
}
|
||||
}
|
||||
|
||||
/// This key's own ordering of two rows at `dir = 0` — the comparator's seven jump-table arms
|
||||
/// (`0x5adbd8`), each returning `0x64a4c0`'s `−1/0/+1` or the level arm's `setge` sign.
|
||||
fn order(self, a: &WhoInfo, b: &WhoInfo) -> Ordering {
|
||||
match self {
|
||||
// `a+0` / `a+0x30`: wire strings, compared case-insensitively. An empty guild is a
|
||||
// real value here (`""` sorts before any name), not a missing row.
|
||||
Self::Name => ascii_ci_cmp(&a.name, &b.name),
|
||||
Self::Guild => ascii_ci_cmp(&a.guild, &b.guild),
|
||||
// `a+0x90`, i32: `cmp; je tie; setge dl; lea edx,[edx*2-1]` — lower level first.
|
||||
Self::Level => a.level.cmp(&b.level),
|
||||
// The three DBC arms: the *localized name* out of `ChrClasses`/`ChrRaces`/`AreaTable`
|
||||
// is what gets compared, which is exactly what the app already resolved into the row.
|
||||
Self::Class => dbc_name_cmp(&a.class, &b.class),
|
||||
Self::Race => dbc_name_cmp(&a.race, &b.race),
|
||||
Self::Zone => dbc_name_cmp(&a.zone, &b.zone),
|
||||
// `0x5adbb2` is the continue label itself.
|
||||
Self::Group => Ordering::Equal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The seven-slot `{key, dir}` chain at `0xc2817c`/`0xc28180`: the sort keys most-recently-used
|
||||
/// first, each with the direction it was last left in. See the module doc.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WhoSortChain {
|
||||
/// `(key, reversed)`, slot 0 first. Every key appears exactly once — a promote permutes, it
|
||||
/// never inserts or drops.
|
||||
slots: [(WhoSortKey, bool); 7],
|
||||
}
|
||||
|
||||
impl Default for WhoSortChain {
|
||||
/// The initialiser `0x5add04`–`0x5add16`: `key[i] = i`, `dir[i] = 0`.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
slots: WhoSortKey::ALL.map(|key| (key, false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WhoSortChain {
|
||||
/// `SortWho`'s promote (`0x5ad980`–`0x5ad9c9`): move `sort_type`'s key to the front, flipping
|
||||
/// its direction **only if it was already there**, and shift the keys it passed down one slot
|
||||
/// each. Keys behind it keep their own remembered directions.
|
||||
pub fn promote(&mut self, sort_type: &str) {
|
||||
let key = WhoSortKey::from_sort_type(sort_type);
|
||||
let Some(at) = self.slots.iter().position(|(k, _)| *k == key) else {
|
||||
// Unreachable: the chain is a permutation of all seven keys. The reference's own
|
||||
// not-found edge (`0x5ad991 jmp 0x5ad9cf`) skips the promote and sorts anyway.
|
||||
return;
|
||||
};
|
||||
let dir = if at == 0 {
|
||||
!self.slots[0].1
|
||||
} else {
|
||||
self.slots[at].1
|
||||
};
|
||||
self.slots[..=at].rotate_right(1);
|
||||
self.slots[0] = (key, dir);
|
||||
}
|
||||
|
||||
/// The comparator `0x5ada00`: walk the chain, return the first key that does not tie —
|
||||
/// negated when that slot's direction is set — and `Equal` when all seven tie.
|
||||
pub fn compare(&self, a: &WhoInfo, b: &WhoInfo) -> Ordering {
|
||||
for (key, reversed) in self.slots {
|
||||
let order = key.order(a, b);
|
||||
if order != Ordering::Equal {
|
||||
return if reversed { order.reverse() } else { order };
|
||||
}
|
||||
}
|
||||
Ordering::Equal
|
||||
}
|
||||
|
||||
/// Order `rows` by the chain — the `qsort` at `0x5ad9cf` (a click) and `0x5ae0e2` (a fresh
|
||||
/// `SMSG_WHO` answer, which is why results arrive already in the current order).
|
||||
///
|
||||
/// The reference's `qsort` is unstable and this is not; the difference is unobservable,
|
||||
/// because the name key never leaves the chain and no two rows of one realm's `/who` answer
|
||||
/// share a name, so [`Self::compare`] is a total order over the rows that can actually occur.
|
||||
pub fn sort(&self, rows: &mut [WhoInfo]) {
|
||||
rows.sort_by(|a, b| self.compare(a, b));
|
||||
}
|
||||
|
||||
/// The chain's keys, front (most recently clicked) first — for tests and diagnostics.
|
||||
#[cfg(test)]
|
||||
fn keys(&self) -> [(WhoSortKey, bool); 7] {
|
||||
self.slots
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x64a4c0` → `0x414310`: a **case-insensitive ASCII** compare, folding only `A`–`Z` by `+0x20`
|
||||
/// on both operands (`0x41434a`–`0x41435c`) and answering `−1/0/+1`.
|
||||
///
|
||||
/// Deliberately not `to_lowercase()`: that is a Unicode fold, and it would order the accented
|
||||
/// names the localized clients allow differently from the reference, which only ever touches the
|
||||
/// 26 ASCII letters.
|
||||
fn ascii_ci_cmp(a: &str, b: &str) -> Ordering {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
for (x, y) in a.iter().zip(b) {
|
||||
match x.to_ascii_lowercase().cmp(&y.to_ascii_lowercase()) {
|
||||
Ordering::Equal => {}
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
// The reference compares NUL-terminated strings, so the shorter one wins its own prefix.
|
||||
a.len().cmp(&b.len())
|
||||
}
|
||||
|
||||
/// The three DBC-backed arms' compare. **A row the id could not resolve TIES** — verified on all
|
||||
/// three arms (class `0x5ada7b`, race `0x5adadf`, zone `0x5adb40`), which are one idiom
|
||||
/// byte-for-byte: three miss conditions (a negative id, an id past the *inclusive* bound, a NULL
|
||||
/// row at an in-range id) zero the row register, and either side being zero jumps to `0x5adbb2`,
|
||||
/// which **is** the chain loop's `inc esi`. So it is not a returned `0` compared against
|
||||
/// something — the key contributes nothing and the next slot decides. `dir` cannot reach it
|
||||
/// either (the `neg` is gated behind the compare the tie branch skips).
|
||||
///
|
||||
/// The tie matters more than it looks: an unresolved id would otherwise sort as `""`, parking
|
||||
/// every unknown zone at the top of the list ahead of Ahn'Qiraj.
|
||||
///
|
||||
/// **The empty string is this side's marker for "no row", and the two sides of the reference
|
||||
/// disagree on purpose.** `GetWhoInfo 0x5ad6e0` substitutes the localized `"UNKNOWN"` on the same
|
||||
/// three legs, so a row can read UNKNOWN in the cell while sorting as though that column did not
|
||||
/// exist. Anything that starts showing UNKNOWN here has to carry the miss to this comparator by
|
||||
/// some route other than the string (wow-re `who-list-sort-law.md` §11.1–§11.2).
|
||||
fn dbc_name_cmp(a: &str, b: &str) -> Ordering {
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return Ordering::Equal;
|
||||
}
|
||||
ascii_ci_cmp(a, b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn row(name: &str, level: u32, class: &str, zone: &str) -> WhoInfo {
|
||||
WhoInfo {
|
||||
name: name.to_string(),
|
||||
guild: String::new(),
|
||||
level,
|
||||
race: "Human".to_string(),
|
||||
class: class.to_string(),
|
||||
zone: zone.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn names(rows: &[WhoInfo]) -> Vec<&str> {
|
||||
rows.iter().map(|r| r.name.as_str()).collect()
|
||||
}
|
||||
|
||||
/// The seeded chain is the reference's `key[i] = i`, all ascending — so a list nobody has
|
||||
/// clicked is ordered zone, then level, then class, then name.
|
||||
#[test]
|
||||
fn the_chain_is_seeded_in_key_order() {
|
||||
assert_eq!(
|
||||
WhoSortChain::default().keys(),
|
||||
[
|
||||
(WhoSortKey::Zone, false),
|
||||
(WhoSortKey::Level, false),
|
||||
(WhoSortKey::Class, false),
|
||||
(WhoSortKey::Group, false),
|
||||
(WhoSortKey::Name, false),
|
||||
(WhoSortKey::Race, false),
|
||||
(WhoSortKey::Guild, false),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The key mapping, including the case-insensitive compares and the name fallback that
|
||||
/// swallows every unrecognised string.
|
||||
#[test]
|
||||
fn the_sort_type_maps_case_insensitively_and_falls_back_to_name() {
|
||||
for (arg, key) in [
|
||||
("zone", WhoSortKey::Zone),
|
||||
("level", WhoSortKey::Level),
|
||||
("class", WhoSortKey::Class),
|
||||
("group", WhoSortKey::Group),
|
||||
("name", WhoSortKey::Name),
|
||||
("race", WhoSortKey::Race),
|
||||
("guild", WhoSortKey::Guild),
|
||||
("LEVEL", WhoSortKey::Level),
|
||||
("Guild", WhoSortKey::Guild),
|
||||
] {
|
||||
assert_eq!(WhoSortKey::from_sort_type(arg), key, "{arg}");
|
||||
}
|
||||
for arg in ["", "sortme", "5", "levels"] {
|
||||
assert_eq!(
|
||||
WhoSortKey::from_sort_type(arg),
|
||||
WhoSortKey::Name,
|
||||
"{arg} must fall back to name"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **B365's headline.** A first click sorts ascending; the *same* key clicked again reverses.
|
||||
#[test]
|
||||
fn a_repeated_click_reverses() {
|
||||
let mut rows = vec![
|
||||
row("Galas", 60, "Warrior", "Elwynn Forest"),
|
||||
row("Erdrin", 60, "Warrior", "Elwynn Forest"),
|
||||
];
|
||||
let mut chain = WhoSortChain::default();
|
||||
|
||||
chain.promote("name");
|
||||
chain.sort(&mut rows);
|
||||
assert_eq!(names(&rows), ["Erdrin", "Galas"], "first click ascends");
|
||||
|
||||
chain.promote("name");
|
||||
chain.sort(&mut rows);
|
||||
assert_eq!(names(&rows), ["Galas", "Erdrin"], "the repeat reverses");
|
||||
|
||||
chain.promote("name");
|
||||
chain.sort(&mut rows);
|
||||
assert_eq!(names(&rows), ["Erdrin", "Galas"], "and back again");
|
||||
}
|
||||
|
||||
/// The flip is `i == 0` ONLY. Clicking a different key in between promotes name from slot 1
|
||||
/// with its remembered direction *carried*, so the third click below does not re-reverse —
|
||||
/// it restores what the second one left, which is the half a naive "toggle on every click"
|
||||
/// implementation gets wrong.
|
||||
#[test]
|
||||
fn promoting_from_behind_carries_the_direction_it_left() {
|
||||
let mut chain = WhoSortChain::default();
|
||||
chain.promote("name"); // ascending
|
||||
chain.promote("name"); // descending
|
||||
chain.promote("level"); // name drops to slot 1, still descending
|
||||
assert_eq!(chain.keys()[1], (WhoSortKey::Name, true));
|
||||
|
||||
chain.promote("name"); // back to the front — NOT a flip
|
||||
assert_eq!(chain.keys()[0], (WhoSortKey::Name, true));
|
||||
|
||||
let mut rows = vec![
|
||||
row("Aaa", 1, "Mage", "Durotar"),
|
||||
row("Bbb", 1, "Mage", "Durotar"),
|
||||
];
|
||||
chain.sort(&mut rows);
|
||||
assert_eq!(names(&rows), ["Bbb", "Aaa"], "still descending");
|
||||
}
|
||||
|
||||
/// The displaced keys stay behind the clicked one, most-recently-used first — so a click is a
|
||||
/// tie-breaker chain, not a fresh single-key sort. Level, then class: rows equal on class
|
||||
/// keep the *level* order the earlier click established.
|
||||
#[test]
|
||||
fn earlier_clicks_survive_as_tie_breakers() {
|
||||
let mut chain = WhoSortChain::default();
|
||||
chain.promote("level");
|
||||
chain.promote("class");
|
||||
assert_eq!(
|
||||
chain.keys()[..2],
|
||||
[(WhoSortKey::Class, false), (WhoSortKey::Level, false)]
|
||||
);
|
||||
|
||||
let mut rows = vec![
|
||||
row("Ca", 40, "Rogue", "Westfall"),
|
||||
row("Aa", 60, "Mage", "Westfall"),
|
||||
row("Ba", 20, "Rogue", "Westfall"),
|
||||
];
|
||||
chain.sort(&mut rows);
|
||||
assert_eq!(
|
||||
names(&rows),
|
||||
["Aa", "Ba", "Ca"],
|
||||
"Mage before Rogue; the two Rogues in the level order the earlier click gave them"
|
||||
);
|
||||
}
|
||||
|
||||
/// The level arm is the numeric one, and ascending means the LOWER level first.
|
||||
#[test]
|
||||
fn level_ascends_before_it_reverses() {
|
||||
let mut rows = vec![row("High", 60, "Mage", "Z"), row("Low", 12, "Mage", "Z")];
|
||||
let mut chain = WhoSortChain::default();
|
||||
chain.promote("level");
|
||||
chain.sort(&mut rows);
|
||||
assert_eq!(names(&rows), ["Low", "High"]);
|
||||
chain.promote("level");
|
||||
chain.sort(&mut rows);
|
||||
assert_eq!(names(&rows), ["High", "Low"]);
|
||||
}
|
||||
|
||||
/// Names fold case the way `0x414310` does — `apple` and `Apple` compare equal, and `Zed`
|
||||
/// sorts after `apple` rather than before it as a byte compare would have it.
|
||||
#[test]
|
||||
fn the_string_arms_fold_ascii_case() {
|
||||
assert_eq!(ascii_ci_cmp("Apple", "apple"), Ordering::Equal);
|
||||
assert_eq!(ascii_ci_cmp("apple", "Zed"), Ordering::Less);
|
||||
assert_eq!(ascii_ci_cmp("Zed", "apple"), Ordering::Greater);
|
||||
assert_eq!(ascii_ci_cmp("Ann", "Anne"), Ordering::Less, "prefix first");
|
||||
}
|
||||
|
||||
/// An unresolved DBC name ties rather than sorting as `""` — otherwise every row whose zone
|
||||
/// id has no `AreaTable` row would pile up at the top of a zone sort.
|
||||
#[test]
|
||||
fn an_unresolved_dbc_name_ties() {
|
||||
let mut chain = WhoSortChain::default();
|
||||
chain.promote("zone");
|
||||
let known = row("Bbb", 10, "Mage", "Westfall");
|
||||
let unknown = row("Aaa", 10, "Mage", "");
|
||||
// Not `Less`: a `""` zone does not sort first, it does not sort at all — the zone key
|
||||
// ties and the chain falls through to the name, where Aaa beats Bbb.
|
||||
assert_eq!(
|
||||
chain.compare(&known, &unknown),
|
||||
Ordering::Greater,
|
||||
"the zone key ties, so the name decides"
|
||||
);
|
||||
assert_eq!(chain.compare(&unknown, &known), Ordering::Less);
|
||||
|
||||
// A *guild* is not a DBC lookup: an empty guild is a real value and sorts first.
|
||||
assert_eq!(ascii_ci_cmp("", "Legacy"), Ordering::Less);
|
||||
}
|
||||
|
||||
/// `"group"` is a live chain key with a dead arm: it displaces whatever was at the front, and
|
||||
/// orders nothing itself, so the list falls straight through to the rest of the chain.
|
||||
#[test]
|
||||
fn the_group_key_displaces_but_orders_nothing() {
|
||||
let mut chain = WhoSortChain::default();
|
||||
chain.promote("level");
|
||||
chain.promote("group");
|
||||
assert_eq!(
|
||||
chain.keys()[..2],
|
||||
[(WhoSortKey::Group, false), (WhoSortKey::Level, false)]
|
||||
);
|
||||
let mut rows = vec![row("A", 60, "Mage", "Z"), row("B", 12, "Mage", "Z")];
|
||||
chain.sort(&mut rows);
|
||||
assert_eq!(
|
||||
names(&rows),
|
||||
["B", "A"],
|
||||
"ordered by level, group having tied"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every key ties ⇒ `Equal`, the comparator's `xor eax,eax` tail. Only reachable for two rows
|
||||
/// that agree on all six live keys.
|
||||
#[test]
|
||||
fn identical_rows_tie_on_every_key() {
|
||||
let chain = WhoSortChain::default();
|
||||
let a = row("Same", 30, "Priest", "Ironforge");
|
||||
assert_eq!(chain.compare(&a, &a.clone()), Ordering::Equal);
|
||||
}
|
||||
}
|
||||
@@ -352,6 +352,7 @@ mod tests {
|
||||
looping: true,
|
||||
}],
|
||||
bbox: ([-0.0127, -0.0118, 0.0], [0.0135, 0.0145, 0.0]),
|
||||
cameras: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -417,16 +417,28 @@ pub struct ModelState {
|
||||
pub facing: f32,
|
||||
/// `SetModelScale` — the model's own scale within the pane, default 1.
|
||||
pub scale: f32,
|
||||
/// `SetCamera(index)` — which of the model's baked camera setups to view it through.
|
||||
pub camera: i32,
|
||||
/// The **pending** camera index (`CSimpleModel+0x320`) — `Some(n)` while the camera question
|
||||
/// is open, `None` once it is settled. The ctor writes `Some(0)`, a standing request for raw
|
||||
/// camera 0, which the model-ready hook applies ([`ModelState::seed_from_facts`]); `SetCamera`
|
||||
/// with no facts yet defers into it (`0x76cec0`'s two early legs). **While it is `Some`, the
|
||||
/// pane draws nothing at all** — the reference's draw gate is
|
||||
/// `76d5f0 cmp [this+0x320],-1 ; jne <skip everything>` (decision 2027).
|
||||
pub camera_pending: Option<i32>,
|
||||
/// The **installed** camera (`+0x31c`) as a RAW index into the file's camera table, or `None`
|
||||
/// for the NULL camera — which is what an index past the table's count installs (`76cf08`)
|
||||
/// and what a file with no cameras always gets. `None` is the **orthographic** render leg;
|
||||
/// `Some(n)` is the perspective one, framed by the file's own record `n`.
|
||||
///
|
||||
/// Raw is the whole point: `0x76cec0` reads the count off `MD20+0x124` and the record at
|
||||
/// `[model+0x3c4] + idx·0x84 + 0x80`, and **never consults `cameraLookup`** — that array is
|
||||
/// the portrait bake's path (wow-re `modelframe-camera-law.md` §2.1).
|
||||
pub camera: Option<u32>,
|
||||
/// `SetPosition(x, y, z)` — the model's offset within the pane's scene.
|
||||
pub position: (f32, f32, f32),
|
||||
/// `SetLight(...)` — the scene's light, stored verbatim as the 14-number tuple the binding
|
||||
/// takes (`enabled, omni, dirX, dirY, dirZ, ambIntensity, ambR, ambG, ambB, dirIntensity,
|
||||
/// dirR, dirG, dirB` plus the leading `enabled`). Opaque here on purpose: the engine core has
|
||||
/// no lighting model, and inventing a typed one would be asserting a scene semantics we have
|
||||
/// not verified.
|
||||
pub light: Option<Vec<f32>>,
|
||||
/// The pane's embedded `CGLight` (`CSimpleModel+0x324`) — see [`ModelLight`]. Typed since
|
||||
/// decision 2027: the render law (§5.1–§5.3) carves every field, its consumer and both of
|
||||
/// `SetLight`'s traps, so the tuple no longer has to be stored opaquely.
|
||||
pub light: ModelLight,
|
||||
/// `SetFogColor(r, g, b, a)` as the reference stores it: **one packed `0xAARRGGBB` dword**,
|
||||
/// which is why its getter is four values wide and why a Set→Get round trip is **lossy** —
|
||||
/// eight bits per channel (decision 1845).
|
||||
@@ -435,10 +447,90 @@ pub struct ModelState {
|
||||
/// not four zeros. It used to be `Option<(f32, f32, f32)>` here: three components, no alpha,
|
||||
/// and `None` for unset — every one of those three wrong.
|
||||
///
|
||||
/// The client's fog surface is seven verbs, not two: this pair plus
|
||||
/// `SetFogNear`/`GetFogNear`/`SetFogFar`/`GetFogFar`/`ClearFog`, which the struct doc names as
|
||||
/// unbuilt.
|
||||
/// All seven fog verbs are built as of decision 2027 — this pair plus
|
||||
/// `SetFogNear`/`GetFogNear`/`SetFogFar`/`GetFogFar`/`ClearFog` — because the render law
|
||||
/// (§5.4) carves every one of their bodies.
|
||||
pub fog_color: u32,
|
||||
/// `+0x3a4` **bit 0** — fog armed. Set by `SetFogColor` (`76f059 or [edi+0x3a4],1`) and by the
|
||||
/// XML `<FogColor>` child; cleared by `ClearFog` (`76f5c5 and [edi+0x3a4],-2` — **bit 0 only**,
|
||||
/// so the colour, near and far all survive a clear and come back on the next `SetFogColor`).
|
||||
/// The ctor leaves it off.
|
||||
pub fog: bool,
|
||||
/// `+0x3ac` — fog near, raw and unclamped from `SetFogNear` (`76f282 fstp`), clamped at `≥ 0`
|
||||
/// only on the XML `fogNear` attribute path (`76cbbb`). Ctor `0.0`.
|
||||
pub fog_near: f32,
|
||||
/// `+0x3b0` — fog far, same shape as [`Self::fog_near`] (`76f432`, XML `fogFar` at `76cbf3`).
|
||||
/// **Ctor `1.0`**, not `0.0`: the fill callback stages `1/(far − near)` and the batch fogs
|
||||
/// only when that is `> 0`, so the ctor's pair is already a valid (if tiny) ramp.
|
||||
pub fog_far: f32,
|
||||
}
|
||||
|
||||
/// A model pane's **armed** fog — [`ModelState::armed_fog`]'s answer, and the whole of what the
|
||||
/// renderer needs: the disarmed values are engine state that nothing draws.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ModelFog {
|
||||
/// The packed `0xAARRGGBB` colour (`+0x3a8`). The alpha byte exists and is **never read** by
|
||||
/// the fill callback (`0x76d680` takes bytes 2,1,0 only).
|
||||
pub color: u32,
|
||||
/// `+0x3ac` / `+0x3b0`, raw. The fill stages `1/(far − near)` and a batch fogs only when that
|
||||
/// is `> 0`, so a `far <= near` pair arms the flag and still draws unfogged.
|
||||
pub near: f32,
|
||||
pub far: f32,
|
||||
}
|
||||
|
||||
impl ModelFog {
|
||||
/// The colour as linear `[r, g, b]` in `0..=1` — the fill callback's own unpack
|
||||
/// (`0x7bbf20` -> bytes 2,1,0 × 1/255).
|
||||
pub fn rgb(&self) -> [f32; 3] {
|
||||
[16, 8, 0].map(|shift| ((self.color >> shift) & 0xff) as f32 / 255.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A model pane's embedded **`CGLight`** — the 0x6c-byte object at `CSimpleModel+0x324` that the
|
||||
/// per-paint fill callback `0x76d680` adds to the model's light collector, and the only light a
|
||||
/// `<Model>` widget has (wow-re `modelframe-render-law.md` §5.1/§5.2).
|
||||
///
|
||||
/// **A plain `<Model>`'s is DISABLED and stays that way unless Lua enables it.** The ctor
|
||||
/// `0x76c8e0` leaves `+0x60 = 0`, so `0x71bf90` returns before adding anything and the collector
|
||||
/// finalizes with zero ambient and zero diffuse — under which a LIT batch draws **black**. That
|
||||
/// is harmless by asset design (every in-game UI M2 is UNLIT on every material, §5.7) and it is
|
||||
/// the faithful answer for an addon's lit one. `<PlayerModel>`'s ctor `0x505680` enables its own
|
||||
/// instead, which is why the paper doll is lit; those panes are the portrait booth's, not this
|
||||
/// widget's.
|
||||
///
|
||||
/// Each colour is stored **already multiplied by its intensity** — `SetLight` folds
|
||||
/// `rgb/255 × intensity` before the copy — which is why there is no separate intensity field.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ModelLight {
|
||||
/// `CGLight+0x60` — enabled. `SetLight`'s first argument writes it, and **only when nonzero**:
|
||||
/// `SetLight(0, …)` returns at `76e2cb` without touching the widget at all, so it is not a
|
||||
/// way to turn a light off (§5.3 trap 1).
|
||||
pub enabled: bool,
|
||||
/// `+0x08` — type. `true` = point/omni (`1`, what both ctors write), `false` = directional.
|
||||
/// Chooses which of the two vector setters `SetLight`'s `(x, y, z)` reaches.
|
||||
pub omni: bool,
|
||||
/// `+0x0c` position (when [`Self::omni`]) or `+0x24` direction (when not) — the direction is
|
||||
/// **normalised on write** by `0x71b6a0`, and is a *from-light* vector.
|
||||
pub vector: [f32; 3],
|
||||
/// `+0x30` ambient, intensity already folded in.
|
||||
pub ambient: [f32; 3],
|
||||
/// `+0x3c` diffuse, intensity already folded in.
|
||||
pub diffuse: [f32; 3],
|
||||
}
|
||||
|
||||
/// The `<Model>` ctor's light (`0x76c8e0`): `0x71b4a0` (type 1, everything else zero), then
|
||||
/// enabled `0` at `76c99c`, type `1` at `76c9a5`, ambient `(1,1,1)` at `76c9b4`–`76c9d3` and
|
||||
/// diffuse `(1,1,1)` at `76c9d6`–`76c9ff`. White, and switched off.
|
||||
impl Default for ModelLight {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
omni: true,
|
||||
vector: [0.0; 3],
|
||||
ambient: [1.0; 3],
|
||||
diffuse: [1.0; 3],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh `Model` pane: no content, sequence 0, unrotated, unit scale, camera 0, at the origin.
|
||||
@@ -459,10 +551,14 @@ impl Default for ModelState {
|
||||
implicit_size: false,
|
||||
facing: 0.0,
|
||||
scale: 1.0,
|
||||
camera: 0,
|
||||
camera_pending: Some(0),
|
||||
camera: None,
|
||||
position: (0.0, 0.0, 0.0),
|
||||
light: None,
|
||||
light: ModelLight::default(),
|
||||
fog_color: 0xffff_ffff,
|
||||
fog: false,
|
||||
fog_near: 0.0,
|
||||
fog_far: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -527,6 +623,11 @@ pub struct ModelFileFacts {
|
||||
/// The header bounding box, raw WoW model space (`min`, `max`) — the implicit rect of a
|
||||
/// size-less `<Model>` (render law §3) and the arrow's re-centring.
|
||||
pub bbox: ([f32; 3], [f32; 3]),
|
||||
/// How many records the file's **camera table** holds (`MD20+0x124`) — the bound
|
||||
/// `Model:SetCamera(n)` is checked against (`0x76cec0`: `76ceeb if idx >= count -> install
|
||||
/// NULL`). `0` for the overwhelming majority of models, which is why a plain `<Model>` almost
|
||||
/// always ends on the orthographic leg. Decision 2027.
|
||||
pub cameras: u32,
|
||||
}
|
||||
|
||||
impl ModelFileFacts {
|
||||
@@ -551,6 +652,13 @@ impl ModelFileFacts {
|
||||
((max[0] - min[0]).max(0.0), (max[1] - min[1]).max(0.0))
|
||||
}
|
||||
|
||||
/// Which camera raw index `idx` installs: `Some(idx)` when the table has it, `None` — the
|
||||
/// **NULL** camera, i.e. the orthographic leg — when it does not. Negative indices land in the
|
||||
/// same place the reference's unsigned `jae` puts them.
|
||||
pub fn camera_at(&self, idx: i32) -> Option<u32> {
|
||||
u32::try_from(idx).ok().filter(|&i| i < self.cameras)
|
||||
}
|
||||
|
||||
/// The loader's idle seed (`0x70ebd0`'s tail, `0x710153`–`0x71019b`): **id 0 (`Stand`) if
|
||||
/// the file owns it, else `animations[0]`'s own id**; `None` only for a file with no
|
||||
/// sequences at all.
|
||||
@@ -647,6 +755,41 @@ impl ModelState {
|
||||
self.armed = None;
|
||||
}
|
||||
}
|
||||
// The model-ready hook's other half (`0x76ce00` `76ce3e`): apply the pending camera index
|
||||
// if the question is still open. The ctor's standing `Some(0)` is what gives a plain
|
||||
// `<Model>` its default camera 0, and it is applied here, once, exactly as the reference
|
||||
// applies it on the asset-ready edge.
|
||||
if let Some(idx) = self.camera_pending {
|
||||
self.install_camera(idx, Some(facts));
|
||||
}
|
||||
}
|
||||
|
||||
/// The pane's fog, **only when it is armed** (`+0x3a4` bit 0) — the fill callback's own gate
|
||||
/// (`76d68a test byte [esi+0x3a4],1 ; je`). A disarmed pane stages nothing, and its collector
|
||||
/// keeps the zeroed `1/(far − near)` that the per-batch fog test refuses.
|
||||
pub fn armed_fog(&self) -> Option<ModelFog> {
|
||||
self.fog.then_some(ModelFog {
|
||||
color: self.fog_color,
|
||||
near: self.fog_near,
|
||||
far: self.fog_far,
|
||||
})
|
||||
}
|
||||
|
||||
/// `0x76cec0` — select a camera by RAW table index. With no facts yet (the reference's "no
|
||||
/// model" / "not ready" legs at `76cece`/`76cedb`) the index is **deferred** into
|
||||
/// [`Self::camera_pending`] and nothing draws until it resolves; with facts, the index is
|
||||
/// bounds-checked against the table's count and either installed or answered with the NULL
|
||||
/// camera, and **either way the pending index is cleared** (`0x76ce80`'s
|
||||
/// `76cead mov [esi+0x320],-1` — installing *any* camera, NULL included, settles the
|
||||
/// question).
|
||||
pub fn install_camera(&mut self, idx: i32, facts: Option<&ModelFileFacts>) {
|
||||
match facts {
|
||||
Some(f) => {
|
||||
self.camera = f.camera_at(idx);
|
||||
self.camera_pending = None;
|
||||
}
|
||||
None => self.camera_pending = Some(idx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the armed sequence stands on the scene clock, under `facts`: wrapped for a looping
|
||||
|
||||
@@ -176,11 +176,11 @@ pub use kinds::{
|
||||
model_key, slider_fraction, slider_grab, ArmedSequence, ButtonFont, ButtonState,
|
||||
ButtonVisualState, ColorSelectState, EditAction, EditBoxState, EditOutcome, EditUnit,
|
||||
FrameKind, InsertMode, KindState, MessageFrameState, MessageLine, MinimapState, ModelFileFacts,
|
||||
ModelPlayHead, ModelState, RegionKind, ScrollFrameState, ScrollingMessageState, SequenceFacts,
|
||||
SliderState, StatusBarState, TooltipState, MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK,
|
||||
MINIMAP_DEFAULT_PLAYER_MODEL, MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN,
|
||||
MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD,
|
||||
TOOLTIP_WRAP_WIDTH,
|
||||
ModelFog, ModelLight, ModelPlayHead, ModelState, RegionKind, ScrollFrameState,
|
||||
ScrollingMessageState, SequenceFacts, SliderState, StatusBarState, TooltipState,
|
||||
MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL,
|
||||
MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP,
|
||||
TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -78,7 +78,13 @@ impl Plugin for CloudsPlugin {
|
||||
// After the submersion verdict: the surfacing edge (wet→dry) must fire the
|
||||
// full rebuild the SAME frame the dome un-hides, or the first visible frame
|
||||
// still shows the submerged-era tile.
|
||||
tick_clouds.after(crate::liquid::SubmersionVerdict),
|
||||
// …and on the READ side of the lighting resolve: the surfacing rebuild
|
||||
// below takes its density and palette from `WowLighting`, so an unordered
|
||||
// tick rebuilds the field from the atmosphere we just LEFT — the underwater
|
||||
// one, whose cloud density is 0.0 in Stranglethorn (B354, decision 2032).
|
||||
tick_clouds
|
||||
.after(crate::liquid::SubmersionVerdict)
|
||||
.in_set(crate::lighting::LightingConsumeSet),
|
||||
// After the resolve: the dome and the painted skybox must agree WITHIN a frame,
|
||||
// or one frame draws both (the ordering `crate::sky`'s gate already takes) —
|
||||
// and after the submersion verdict, for the same one-frame-agreement reason.
|
||||
|
||||
@@ -37,16 +37,26 @@
|
||||
//! attachments from the owner's node; independent sampling would flicker at shadow edges mid-swing).
|
||||
//!
|
||||
//! Interplay: the interior classifier ([`crate::interior`]) owns a part's tag while it stands in a WMO
|
||||
//! room (packed floor colour — no sun indoors, so no shade either); this system skips those parts and
|
||||
//! room (an SH-probe slot — no sun indoors, so no shade either); this system skips those parts and
|
||||
//! runs after the classifier to re-assert the byte over its exterior reclaim. Fades own the alpha field
|
||||
//! only (they write through `with_alpha`), so shade rides through appear/despawn/zoom feathering.
|
||||
//!
|
||||
//! **The descendant tree is a TRANSFORM relation, not a light one, and the difference is a bug we
|
||||
//! shipped** (B373). A WMO-display GameObject's doodad props — a transport's cabin furniture — are
|
||||
//! parented under the net entity so they sail with the deck (decision 0474), but their light is
|
||||
//! their own baked MODD colour folded into an SH probe, the reference's `CMapDoodadDef` provider
|
||||
//! (`0x6a8050`) rather than the WENTITY node this file models. They were nonetheless in the walk,
|
||||
//! and since the shade byte overlaps the probe slot in bits 6..=13, every one of them was pushed
|
||||
//! onto a *different* probe: a neighbouring prop's light where the renamed index happened to be
|
||||
//! live, and an unallocated — zeroed — row where it was not, which draws solid black. Both write
|
||||
//! sites now ask [`probe_payload`], the payload question rather than the classifier question.
|
||||
|
||||
use benilla_assets::AdtTile;
|
||||
use bevy::mesh::MeshTag;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::interior::{classify_entity_interior, InteriorLit};
|
||||
use crate::mesh_tag::{shade_of, with_shade};
|
||||
use crate::mesh_tag::{shade_of, with_shade, InteriorProbePayload};
|
||||
use crate::terrain_stream::{doodad_ground_shade, ShadeResolve, TerrainStreamer};
|
||||
|
||||
// Decision 0354 generalized this file from "the MCSH ground-shade byte" to the entity light
|
||||
@@ -276,15 +286,27 @@ pub(crate) fn update_ground_shade(
|
||||
Option<&Visibility>,
|
||||
)>,
|
||||
children: Query<&Children>,
|
||||
// Parts are matched by carrying a `MeshTag`; interior-classified ones are skipped (their payload
|
||||
// is the packed floor colour). Fading parts are NOT skipped — shade and fade own disjoint fields.
|
||||
// Parts are matched by carrying a `MeshTag`; a part whose payload is a PROBE SLOT is skipped,
|
||||
// because the shade byte lives in the same bits (see [`probe_payload`]). Fading parts are NOT
|
||||
// skipped — shade and fade own disjoint fields.
|
||||
mut parts: Query<
|
||||
(&mut MeshTag, Option<&InteriorLit>),
|
||||
(
|
||||
&mut MeshTag,
|
||||
Option<&InteriorLit>,
|
||||
Has<InteriorProbePayload>,
|
||||
),
|
||||
Without<crate::billboard::BillboardCard>,
|
||||
>,
|
||||
// A card is a world ROOT (the facing system owns its transform), so the descendant walk below
|
||||
// cannot reach one — it carries its owner instead. Disjoint from `parts` by the filter above.
|
||||
mut cards: Query<(&crate::billboard::BillboardCard, &mut MeshTag)>,
|
||||
// It asks the same payload question: an interior prop's glow card carries its doodad's probe
|
||||
// slot, and this pass reaches it by walking UP from the card's owner (B373).
|
||||
mut cards: Query<(
|
||||
&crate::billboard::BillboardCard,
|
||||
&mut MeshTag,
|
||||
Option<&InteriorLit>,
|
||||
Has<InteriorProbePayload>,
|
||||
)>,
|
||||
// Reused across frames: each shaded ROOT → its shade byte this frame (a few hundred entries).
|
||||
// The card pass walks `ChildOf` up to the nearest such root — this map used to record every
|
||||
// descendant too (~10-20k inserts/frame) so that walk could be a single lookup.
|
||||
@@ -410,11 +432,11 @@ pub(crate) fn update_ground_shade(
|
||||
// joint entities deeper down — same full-tree walk as the self-fade). Change-gated per part on
|
||||
// the byte, so a settled entity writes nothing and never re-triggers render extraction.
|
||||
for part in children.iter_descendants(root) {
|
||||
let Ok((mut tag, lit)) = parts.get_mut(part) else {
|
||||
let Ok((mut tag, lit, own_probe)) = parts.get_mut(part) else {
|
||||
continue;
|
||||
};
|
||||
if lit.is_some_and(InteriorLit::is_bake) {
|
||||
continue; // the footprint-bake lane: the classifier owns the payload (probe slot)
|
||||
if probe_payload(lit, own_probe) {
|
||||
continue;
|
||||
}
|
||||
if shade_of(tag.0) != byte {
|
||||
tag.0 = with_shade(tag.0, byte);
|
||||
@@ -429,7 +451,10 @@ pub(crate) fn update_ground_shade(
|
||||
// anchor or a deep joint) resolves to the NEAREST shaded root above it: with nested roots (a
|
||||
// mounted unit — rider and mount each carry a node) that is the mount's, matching the
|
||||
// one-node-per-object structure above; the old whole-tree map made this pick last-writer-wins.
|
||||
for (card, mut tag) in &mut cards {
|
||||
for (card, mut tag, lit, own_probe) in &mut cards {
|
||||
if probe_payload(lit, own_probe) {
|
||||
continue;
|
||||
}
|
||||
let Some(byte) = card_root_shade(&root_shade, &child_of, card.follows()) else {
|
||||
continue; // a fixed terrain doodad's card — its shade rides the material selector
|
||||
};
|
||||
@@ -439,6 +464,27 @@ pub(crate) fn update_ground_shade(
|
||||
}
|
||||
}
|
||||
|
||||
/// **Does this instance's `MeshTag` payload carry an SH-probe slot?** If it does, this file must
|
||||
/// not touch it: the shade byte occupies bits 6..=13 and the probe slot 6..=18, so a shade write
|
||||
/// renames the slot to `(slot & 0x1f00) | byte` — a foreign probe, or an unallocated (zeroed ⇒
|
||||
/// **black**) row.
|
||||
///
|
||||
/// Two populations answer yes, by two different components, and the guard used to know only the
|
||||
/// first:
|
||||
/// - the interior classifier's **Bake law** — an entity part standing in a WMO room, whose slot
|
||||
/// the classifier re-seats as the body moves ([`InteriorLit::is_bake`]);
|
||||
/// - a lit interior **MODD prop**, whose slot was folded once at spawn and never moves
|
||||
/// ([`InteriorProbePayload`]).
|
||||
///
|
||||
/// The second only ever meets this walk on a WMO-display GameObject, whose doodad props are
|
||||
/// parented under the net entity so they ride a moving transport (decision 0474) — a *transform*
|
||||
/// relationship that the descendant walk read as a *light* one. That is bug B373: every cabin
|
||||
/// prop on every 1.12 transport read probe slot `(its own & 0x1f00) | the boat's shade byte`, and
|
||||
/// the ones that landed on an unallocated row drew as solid black silhouettes beside lit walls.
|
||||
fn probe_payload(lit: Option<&InteriorLit>, own_probe: bool) -> bool {
|
||||
own_probe || lit.is_some_and(InteriorLit::is_bake)
|
||||
}
|
||||
|
||||
/// The shade byte a card inherits: the NEAREST shaded root at or above `follows` (the owner itself
|
||||
/// first, then the `ChildOf` chain). `None` — no owner, or no shaded root over it — skips the card,
|
||||
/// exactly as the old whole-tree map's miss did.
|
||||
@@ -554,6 +600,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The payload question, both populations** (B373). The guard used to ask "is this part on
|
||||
/// the classifier's Bake law", which is only one of the two ways a `MeshTag` comes to hold a
|
||||
/// probe slot; a WMO doodad prop holds one from spawn and carries no `InteriorLit` at all.
|
||||
/// A transport's cabin furniture is where the second population lands inside an entity's
|
||||
/// descendant walk, and every one of those props drew under a foreign probe.
|
||||
///
|
||||
/// (The Bake half is the classifier's own state and is pinned by `interior`'s tests; what is
|
||||
/// new here is that the marker answers on its own, whatever the classifier says.)
|
||||
#[test]
|
||||
fn a_spawned_probe_payload_is_hands_off_with_no_interior_lit_at_all() {
|
||||
let matte = InteriorLit::new(crate::interior::InteriorKind::Matte, Handle::default());
|
||||
assert!(
|
||||
probe_payload(None, true),
|
||||
"a spawn-time MODD prop owns its payload, with no InteriorLit to say so"
|
||||
);
|
||||
assert!(
|
||||
probe_payload(Some(&matte), true),
|
||||
"and it owns it whatever law the classifier would have applied"
|
||||
);
|
||||
assert!(
|
||||
!probe_payload(Some(&matte), false),
|
||||
"the matte indoor lane carries no slot — the byte is its field"
|
||||
);
|
||||
assert!(
|
||||
!probe_payload(None, false),
|
||||
"an ordinary exterior part takes the shade byte"
|
||||
);
|
||||
}
|
||||
|
||||
/// Nested shade roots — a mounted unit: rider root and mount root each carry a node, the mount
|
||||
/// a descendant of the rider. A card under the MOUNT takes the mount's byte (the nearest root),
|
||||
/// pinned here because the old whole-tree map answered this by insert order.
|
||||
|
||||
@@ -948,7 +948,19 @@ impl Plugin for FfxGlowPlugin {
|
||||
ExtractResourcePlugin::<FfxHazeMix>::default(),
|
||||
ExtractResourcePlugin::<FfxWave>::default(),
|
||||
))
|
||||
.add_systems(Update, (sync_gain, sync_haze, sync_wave, ensure_ffx_glow));
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// The gain is the zone's `LightParams.glow`, so the sync is on the resolve's
|
||||
// read side; the haze floor and the wave's arm are the camera-eye submersion
|
||||
// verdict, so they are after the slot that writes it. Unordered, both flipped
|
||||
// a frame late — the underwater blur and warp outlived the surfacing frame
|
||||
// they belong to, exactly like the sky dome's stops (decision 2032).
|
||||
sync_gain.in_set(crate::lighting::LightingConsumeSet),
|
||||
(sync_haze, sync_wave).after(crate::liquid::SubmersionVerdict),
|
||||
ensure_ffx_glow,
|
||||
),
|
||||
);
|
||||
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -418,7 +418,11 @@ impl Plugin for InteriorPlugin {
|
||||
// placements it rays are the ones this frame's residency published.
|
||||
.add_systems(
|
||||
Update,
|
||||
classify_entity_interior.after(crate::wmo_portal::WmoPvsSet),
|
||||
classify_entity_interior
|
||||
.after(crate::wmo_portal::WmoPvsSet)
|
||||
// The fold reads the resolved `WowLighting`, so it belongs on the resolve's
|
||||
// read side (`lighting::LightingConsumeSet`, decision 2032).
|
||||
.in_set(crate::lighting::LightingConsumeSet),
|
||||
)
|
||||
.add_observer(enqueue_on_fade_latch);
|
||||
}
|
||||
|
||||
@@ -63,9 +63,20 @@ impl LightBlob {
|
||||
/// Fog: colour, far distance, and whether the shader applies it. Near is always 0 and the
|
||||
/// farclip wall stays inert — the reference's off-world fog rows (`CharModelFogInfo` and
|
||||
/// `AccountLogin.xml`) carry a far and nothing else.
|
||||
pub fn fog(mut self, rgb: [f32; 3], far: f32, on: bool) -> Self {
|
||||
pub fn fog(self, rgb: [f32; 3], far: f32, on: bool) -> Self {
|
||||
self.fog_span(rgb, 0.0, far, on)
|
||||
}
|
||||
|
||||
/// Fog with an explicit **near**, the ramp's low end (`fog_params.x`): the factor is
|
||||
/// `(far − eye_z)/(far − near)` clamped, so near is where the fog first bites and not a
|
||||
/// cosmetic. The one producer that can state one is the `<Model>` widget's `SetFogNear`
|
||||
/// (decision 2027) — every other off-world blob leaves it at 0, which is what
|
||||
/// [`Self::fog`] passes and what `CharModelFogInfo`'s own `SetFogNear(0)` says.
|
||||
/// The farclip wall (`.w`) stays inert at `10_000`: it is a per-pixel *discard*, not a fade,
|
||||
/// and an off-world scene has no clip distance to enforce.
|
||||
pub fn fog_span(mut self, rgb: [f32; 3], near: f32, far: f32, on: bool) -> Self {
|
||||
self.rows[4] = [rgb[0], rgb[1], rgb[2], if on { 1.0 } else { 0.0 }];
|
||||
self.rows[5] = [0.0, far, 0.0, 10_000.0];
|
||||
self.rows[5] = [near, far, 0.0, 10_000.0];
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
@@ -286,6 +286,41 @@ pub struct GameClock {
|
||||
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct LightingResolveSet;
|
||||
|
||||
/// **The read side of [`LightingResolveSet`] — every `Update` system that reads the resolved
|
||||
/// [`WowLighting`] belongs in this set.** Configured once, here, as
|
||||
/// `LightingConsumeSet.after(LightingResolveSet)`; a consumer joins the contract with
|
||||
/// `.in_set(..)` instead of remembering a bespoke `.after(..)`, which is precisely what five of
|
||||
/// them did not remember (decision 2032).
|
||||
///
|
||||
/// **Why the contract needs a name of its own.** [`resolve::update_time_lighting`] is a *late*
|
||||
/// system by construction — it waits on the wire drain ([`crate::schedule::WorldStage::Net`]),
|
||||
/// the weather tick and the submersion verdict, each of which waits on something else. A consumer
|
||||
/// that declares no ordering waits on nothing, so Bevy's executor starts it at the top of
|
||||
/// `Update` and it reads the value the resolve wrote **last** frame. That is not a race: it is
|
||||
/// deterministic, it happens on every frame, and it is invisible for as long as the atmosphere
|
||||
/// only drifts with the clock.
|
||||
///
|
||||
/// It stops being invisible at a **submersion crossing**, where the atmosphere does not drift but
|
||||
/// jumps — the whole underwater `LightParams` swaps in or out in one frame, on the very frame
|
||||
/// [`crate::sky`]'s and [`crate::clouds`]'s own gates un-hide the domes. Report B354 is that
|
||||
/// frame photographed: coming up off the Savage Coast, the first dry frame painted the sky dome
|
||||
/// in Stranglethorn's UNDERWATER sky stops (`LightParams` 27) over an already-dry world, and
|
||||
/// [`crate::clouds`]'s surfacing full-rebuild — the fix for the 0.4 s cloud pop-in — rebuilt the
|
||||
/// coverage field at the *underwater* cloud density, which Stranglethorn authors as 0.0, so it
|
||||
/// rebuilt an empty sky and the clouds crept back band by band exactly as they had before that
|
||||
/// fix existed.
|
||||
///
|
||||
/// A consumer that runs in `PostUpdate` (the material/GPU pushes, the camera-anchored follows) is
|
||||
/// already after the resolve by schedule and needs nothing from this set.
|
||||
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct LightingConsumeSet;
|
||||
|
||||
/// The one place [`LightingConsumeSet`] is tied to [`LightingResolveSet`] — a named function so
|
||||
/// the test below locks the SAME edge the plugin installs, rather than a copy of it.
|
||||
pub(crate) fn configure_lighting_sets(app: &mut App) {
|
||||
app.configure_sets(Update, LightingConsumeSet.after(LightingResolveSet));
|
||||
}
|
||||
|
||||
/// The lighting subsystem: registers the WoW-light resource + game-clock, a **black** background
|
||||
/// (Phase 0 — the DBC sky comes back in a later step), and the two per-frame systems that resolve the
|
||||
/// `Light.dbc` values for the time of day and push them onto the materials.
|
||||
@@ -293,6 +328,8 @@ pub(crate) struct LightingPlugin;
|
||||
|
||||
impl Plugin for LightingPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// The read side of the resolve, configured once (see [`LightingConsumeSet`]).
|
||||
configure_lighting_sets(app);
|
||||
app.init_resource::<WowLighting>()
|
||||
.init_resource::<PropProbes>()
|
||||
.init_resource::<GameClock>()
|
||||
@@ -321,3 +358,138 @@ impl Plugin for LightingPlugin {
|
||||
global_light::register(app);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ordering_tests {
|
||||
use super::*;
|
||||
|
||||
/// The edge itself, asserted the way the defect presented: **not** "did they happen to run in
|
||||
/// this order" — B354's whole point is that an unordered pair *does* run in some order, one
|
||||
/// the executor picks and that changes with the graph around it — but "is the order
|
||||
/// DETERMINED". Two conflicting systems with no path between them are an *ambiguity*, which
|
||||
/// Bevy will name when asked; deleting or inverting [`configure_lighting_sets`] puts the
|
||||
/// ambiguity back and this fails.
|
||||
///
|
||||
/// (An earlier cut of this test asserted the observed order and passed with the edge removed,
|
||||
/// because the executor's arbitrary choice happened to be the right one. That is exactly the
|
||||
/// reassurance the bug was hiding behind for months.)
|
||||
#[test]
|
||||
fn consumers_are_ordered_against_the_resolve() {
|
||||
use bevy::ecs::schedule::{LogLevel, ScheduleBuildSettings, ScheduleBuildWarning};
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
struct Probe(u32);
|
||||
|
||||
let mut app = App::new();
|
||||
app.init_resource::<Probe>();
|
||||
configure_lighting_sets(&mut app);
|
||||
app.edit_schedule(Update, |s| {
|
||||
s.set_build_settings(ScheduleBuildSettings {
|
||||
ambiguity_detection: LogLevel::Warn,
|
||||
..default()
|
||||
});
|
||||
});
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
(|mut p: ResMut<Probe>| p.0 += 1).in_set(LightingConsumeSet),
|
||||
(|mut p: ResMut<Probe>| p.0 += 1).in_set(LightingResolveSet),
|
||||
),
|
||||
);
|
||||
app.update();
|
||||
let warnings = app
|
||||
.get_schedule(Update)
|
||||
.expect("Update schedule")
|
||||
.warnings()
|
||||
.iter()
|
||||
.filter(|w| matches!(w, ScheduleBuildWarning::Ambiguity(_)))
|
||||
.count();
|
||||
assert_eq!(
|
||||
warnings, 0,
|
||||
"a `LightingConsumeSet` system and a `LightingResolveSet` system are ambiguous — \
|
||||
`configure_lighting_sets` no longer orders the read side after the resolve"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Every `Update` reader of [`WowLighting`] in this crate joins the contract.** The defect
|
||||
/// B354 photographed was not a subtle one in any single system — it was five systems that
|
||||
/// each read the resolved atmosphere and none of which said when, so the executor started
|
||||
/// them at the top of `Update` and they got last frame's. A named set only helps if joining
|
||||
/// it is not something a sixth consumer can quietly forget, so this reads the crate's own
|
||||
/// source and insists.
|
||||
///
|
||||
/// The allowlist is the set of readers that are after the resolve **by schedule** rather than
|
||||
/// by the set — every one of them runs in `PostUpdate` or is chained onto the resolve itself.
|
||||
/// Adding a name here is a claim about WHEN it runs; make it only with the reason.
|
||||
///
|
||||
/// Scoped to this crate's sources — `benilla-app`'s three readers are outside what a
|
||||
/// `benilla-world` test can see (two are after the resolve by schedule; `minimap` is a
|
||||
/// deliberate exception, decision 2032).
|
||||
#[test]
|
||||
fn every_update_reader_joins_the_consume_set() {
|
||||
/// after-by-schedule, so the set would be redundant — file → why.
|
||||
const ALLOWED: &[(&str, &str)] = &[
|
||||
(
|
||||
"lighting/resolve.rs",
|
||||
"apply_sky_backdrop is .chain()ed onto the resolve",
|
||||
),
|
||||
(
|
||||
"lighting/global_light.rs",
|
||||
"build_light_data: PostUpdate, .after(update_time_lighting)",
|
||||
),
|
||||
(
|
||||
"sun/follow.rs",
|
||||
"the celestial follows: PostUpdate, BillboardPlace",
|
||||
),
|
||||
("weather/precip/mod.rs", "push_precip: PostUpdate"),
|
||||
];
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut offenders = Vec::new();
|
||||
let mut stack = vec![root.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).expect("read_dir") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
if path.extension().is_none_or(|e| e != "rs") {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).expect("read source");
|
||||
// A system param, not a doc mention: `Res<…WowLighting>` on one line.
|
||||
let reads = text.lines().any(|l| {
|
||||
!l.trim_start().starts_with("//")
|
||||
&& l.contains("Res<")
|
||||
&& l.contains("WowLighting>")
|
||||
});
|
||||
// Membership means a real `.in_set(..)` CALL, not a doc comment naming the set —
|
||||
// an early cut of this check passed a file whose only mention was the comment
|
||||
// explaining why it joined.
|
||||
let joins = text.lines().any(|l| {
|
||||
!l.trim_start().starts_with("//")
|
||||
&& l.contains("in_set(")
|
||||
&& l.contains("LightingConsumeSet")
|
||||
});
|
||||
if !reads || joins {
|
||||
continue;
|
||||
}
|
||||
let rel = path
|
||||
.strip_prefix(&root)
|
||||
.expect("under src")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
if !ALLOWED.iter().any(|(f, _)| *f == rel) {
|
||||
offenders.push(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"these read `WowLighting` without joining `LightingConsumeSet`: {offenders:?}\n\
|
||||
Order the system `.in_set(crate::lighting::LightingConsumeSet)`, or — if it already \
|
||||
runs after the resolve by schedule (PostUpdate, or chained onto it) — add it to \
|
||||
ALLOWED here with the reason."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,40 @@ pub(crate) use surface::{
|
||||
spawn_liquids, spawn_wmo_liquids, LiquidAssets, LiquidSoundSource, LiquidSurface,
|
||||
};
|
||||
|
||||
/// `WOW_FORCE_SUB=<frames>` — **hold the camera-eye verdict submerged for the first `<frames>`
|
||||
/// frames, then release it.** The surfacing crossing on demand, with no server, no swim and no
|
||||
/// water under the camera: everything that forks on the verdict (the atmosphere, the sky and
|
||||
/// cloud domes' gates, the drift cloud, the FFX haze and warp) takes the wet→dry edge on a frame
|
||||
/// this names, so a transition artefact can be photographed or logged deterministically inside
|
||||
/// the capture harness (`WOW_CAPTURE=<scenario>`).
|
||||
///
|
||||
/// Built for B354 (decision 2032), where the whole defect lived in the ONE frame after the edge
|
||||
/// and the reported spot was a dusk swim off the Savage Coast — a place a screenshot harness
|
||||
/// cannot get to and a bug a still frame cannot catch. It reproduced in `water-noon` in one
|
||||
/// command.
|
||||
fn forced_submersion_frames() -> u32 {
|
||||
std::env::var("WOW_FORCE_SUB")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The [`forced_submersion_frames`] override, applied after the real probe so it overwrites a
|
||||
/// genuine verdict rather than racing it. Prints the release frame, which is the frame every
|
||||
/// crossing artefact is read against. Registered only when the env names a hold.
|
||||
fn force_submersion(mut underwater: ResMut<Underwater>, mut frame: Local<u32>) {
|
||||
let hold = forced_submersion_frames();
|
||||
*frame += 1;
|
||||
if *frame <= hold {
|
||||
underwater.0 = benilla_formats::Submersion::Water;
|
||||
} else if *frame == hold + 1 {
|
||||
info!(
|
||||
"WOW_FORCE_SUB: frame {} — eye verdict released to Dry",
|
||||
*frame
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame slot where [`Underwater`] is written — the label every consumer of the submersion
|
||||
/// verdict orders itself `.after(..)`. The submerged view is a whole-screen swap (atmosphere, clear
|
||||
/// colour, and the sky-pass suppression all flip on it), so a consumer reading a frame-old verdict
|
||||
@@ -161,6 +195,17 @@ impl Plugin for LiquidPlugin {
|
||||
.before(bevy::camera::visibility::VisibilitySystems::VisibilityPropagate)
|
||||
.run_if(|| std::env::var_os("WOW_NO_LIQUID").is_some()),
|
||||
);
|
||||
// The scripted surfacing crossing (see [`force_submersion`]) — the node is only added
|
||||
// when the env asks for it, so an ordinary run carries neither the system nor a per-frame
|
||||
// run condition that would re-read the environment to say "no" 60 times a second.
|
||||
if forced_submersion_frames() > 0 {
|
||||
app.add_systems(
|
||||
Update,
|
||||
force_submersion
|
||||
.after(query::detect_submersion)
|
||||
.in_set(SubmersionVerdict),
|
||||
);
|
||||
}
|
||||
// The underwater drift cloud — the one thing in this subsystem that RENDERS because the
|
||||
// eye is submerged, rather than answering where the liquid is (see the layout note above).
|
||||
drift::register(app);
|
||||
|
||||
@@ -98,8 +98,17 @@
|
||||
//! re-opaque it — since 0755 that writer carries alpha through rather than forcing it).
|
||||
//! 5. `entity_shade::update_ground_shade` owns the **shade field** on entity M2 parts (decision
|
||||
//! 0173): read-modify-write via [`with_shade`], never touching alpha — so it composes with 1–4
|
||||
//! instead of racing them. It skips interior-classified parts (their payload is a colour) and
|
||||
//! runs after 2 to re-assert the byte over 2's exterior reclaim.
|
||||
//! instead of racing them, and runs after 2 to re-assert the byte over 2's exterior reclaim.
|
||||
//!
|
||||
//! **It must skip every part whose payload is a PROBE SLOT, and there are TWO such
|
||||
//! populations, not one.** The shade byte and the probe slot overlap in bits 6..=13, so a
|
||||
//! shade write into a probe payload silently renames the slot — `(slot & 0x1f00) | byte`,
|
||||
//! a foreign probe or an unallocated (zeroed ⇒ **black**) row. The classifier's Bake parts
|
||||
//! are one population and answer with `InteriorLit::is_bake`; a WMO doodad prop spawned with
|
||||
//! its own folded probe is the other, and it holds no `InteriorLit` at all — it answers with
|
||||
//! [`InteriorProbePayload`]. Asking only the first question is B373: a transport's cabin
|
||||
//! furniture is parented under the GameObject for TRANSFORM reasons, so the entity light
|
||||
//! node's descendant walk reached props it does not light, and every one of them went black.
|
||||
//!
|
||||
//! A further *payload* writer (stealth, ghost form, …) should claim reserved bits through a typed
|
||||
//! accessor here — never a new ad-hoc whole-payload convention (decision 0066's rule, upheld by
|
||||
@@ -111,6 +120,21 @@
|
||||
//! (dropping the flag); running after them re-asserts it the same frame, so they never need to know
|
||||
//! it exists.
|
||||
|
||||
/// **This instance's payload is the interior-probe mode** — bits 6..=18 are an SH-probe table
|
||||
/// slot ([`probe_bits`]), not a ground-shade byte. Spawned onto every batch of a lit interior
|
||||
/// MODD prop (`terrain_stream`'s placed-model assembler), cards included, and read by the
|
||||
/// exterior-payload writer as its "hands off" (`entity_shade`).
|
||||
///
|
||||
/// It exists because the *other* probe-slot population — the interior classifier's Bake-law
|
||||
/// entity parts — is recognised by a component the props do not carry (`InteriorLit`), and the
|
||||
/// shade writer's guard asked for that one instead of asking the payload question. Where the two
|
||||
/// populations meet is a WMO-display GameObject: its doodad props are parented under the net
|
||||
/// entity so they ride a moving transport, which puts them inside the entity light node's
|
||||
/// descendant walk while their light is their own baked MODD colour (the reference's
|
||||
/// `CMapDoodadDef` provider `0x6a8050`, never the WENTITY node). See decision 2031 / bug B373.
|
||||
#[derive(bevy::prelude::Component)]
|
||||
pub struct InteriorProbePayload;
|
||||
|
||||
/// Bit 31 of the `MeshTag`: the hover/target **model-brighten** flag (the real client's
|
||||
/// per-model highlight emissive — `SetHighlight 0x614550` writing the config RGB into the CM2;
|
||||
/// wow-re `object-layer/scratch/selection-circle.md` PART 2). The shader adds the emissive lift
|
||||
@@ -488,6 +512,28 @@ mod tests {
|
||||
assert_eq!(with_exterior_reset(0) & ALPHA_MASK, ALPHA_MASK);
|
||||
}
|
||||
|
||||
/// **Why the shade writer must never touch a probe payload** (B373): the byte lives in bits
|
||||
/// 6..=13 and the slot in 6..=18, so a shade write does not *corrupt* the slot in a way
|
||||
/// anything downstream can notice — it RENAMES it, to `(slot & 0x1f00) | byte`, which is a
|
||||
/// perfectly well-formed index into somebody else's probe (or into an unallocated, zeroed
|
||||
/// row: solid black). Pinned here because the failure has no error path anywhere: the prop
|
||||
/// simply draws under another prop's light.
|
||||
#[test]
|
||||
fn a_shade_write_renames_a_probe_slot_instead_of_breaking_it() {
|
||||
let slot = 440u16; // 0b1_1011_1000 — bits 14..=18 hold 1, bits 6..=13 hold 184
|
||||
let tag = with_shade(probe_bits(slot), 191);
|
||||
assert_eq!(
|
||||
(tag & PROBE_MASK) >> PROBE_SHIFT,
|
||||
447,
|
||||
"the boat's shade byte replaced the slot's low 8 bits: 1<<8 | 191"
|
||||
);
|
||||
assert_eq!(
|
||||
alpha_of(tag),
|
||||
1.0,
|
||||
"and the alpha field rode through intact"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_shade_materializes_the_untagged_sentinel_as_opaque() {
|
||||
// Shading an untagged (payload 0) instance must not defeat the "0 ⇒ opaque" rule by making
|
||||
|
||||
@@ -127,7 +127,11 @@ impl Plugin for SkyPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
update_sky_colors,
|
||||
// The dome's stops are the resolved atmosphere, so the push belongs on the
|
||||
// READ side of the resolve (`lighting::LightingConsumeSet`) — unordered it
|
||||
// runs at the top of `Update` and paints last frame's palette, which on a
|
||||
// surfacing frame is the underwater one (B354, decision 2032).
|
||||
update_sky_colors.in_set(crate::lighting::LightingConsumeSet),
|
||||
// The dome stands down for a WMO skybox, so its gate must read the SETTLED
|
||||
// resolve, not whichever side of it the executor picked (`crate::skybox`).
|
||||
apply_sky_visibility
|
||||
|
||||
@@ -57,7 +57,7 @@ pub use window::StreamWindow;
|
||||
// The WMO prop-light machinery lives spawn-side (0830's named carve, executed in 0832); the two
|
||||
// outside consumers — `crate::interior` and `crate::entities`' `wmo_props` — keep their
|
||||
// `terrain_stream::X` paths, same as the `queries` items below.
|
||||
pub use spawn::prop_light::{fold_interior_probe, interior_light_up, PropLobeLight};
|
||||
pub use spawn::prop_light::{fold_interior_probe, hex_word, interior_light_up, PropLobeLight};
|
||||
// The shared placed-model assembler + the off-thread collider build — also the WMO-gameobject
|
||||
// doodad-prop path's spawner (`crate::entities`' `wmo_props`: the ship's sails ride the streamed
|
||||
// gameobject entity, and its cargo hulls ride the boat's kinematic body).
|
||||
|
||||
@@ -617,9 +617,15 @@ pub fn spawn_model_entities(
|
||||
// (`mesh_tag::probe_bits` — bits 16-29 since the 0355 re-lane): this site kept the old
|
||||
// bits-0..=15 write through that re-lane, so every static interior prop read probe slot 0,
|
||||
// taking whichever probe won the streaming race — the director's inn-doodad regression.
|
||||
let mesh_tag = match interior_slot {
|
||||
Some(slot) if interior_probe => MeshTag(crate::mesh_tag::probe_bits(slot)),
|
||||
_ => MeshTag(alpha_bits(1.0)),
|
||||
// ONE decision for "does this batch's payload carry a probe slot", read twice below —
|
||||
// once for the tag's bits and once for the component that SAYS so
|
||||
// ([`crate::mesh_tag::InteriorProbePayload`]). Two independent predicates could drift,
|
||||
// and a reader that disagrees with the writer about which payload a part is on is
|
||||
// exactly B373.
|
||||
let probe_slot = interior_slot.filter(|_| interior_probe);
|
||||
let mesh_tag = match probe_slot {
|
||||
Some(slot) => MeshTag(crate::mesh_tag::probe_bits(slot)),
|
||||
None => MeshTag(alpha_bits(1.0)),
|
||||
};
|
||||
// A billboard batch (glow card / chain) faces the camera each frame, so its transform is owned
|
||||
// by the billboard system. It still distance-fades with its doodad (same `radius` band) — the
|
||||
@@ -728,6 +734,16 @@ pub fn spawn_model_entities(
|
||||
(entity, local_center)
|
||||
};
|
||||
by_batch[batch_idx] = Some(entity);
|
||||
// This batch's payload is a PROBE SLOT, said as a component so the exterior-payload
|
||||
// writer can see it (`mesh_tag::InteriorProbePayload`). Cards included, for the same
|
||||
// reason they carry the slot at all: they are batches of the same model, shaded through
|
||||
// the same light node (0778) — and `entity_shade`'s card pass reaches a card by walking
|
||||
// UP from its owner, a route its descendant-walk guard never covers.
|
||||
if probe_slot.is_some() {
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(crate::mesh_tag::InteriorProbePayload);
|
||||
}
|
||||
// Animated material alpha (decision 0130 phase 2): the rare batch whose colour-alpha/weight
|
||||
// tracks animate (fire flicker) or constantly dim gets its per-instance sampler; the
|
||||
// visibility authority composes the value into the render-alpha tag + the A ≤ 0 cull.
|
||||
|
||||
@@ -53,10 +53,7 @@ impl PropLight {
|
||||
/// as long as it did (decision 0969). `sky-lit` is the exterior lane; the interior lane prints
|
||||
/// the MODD-colour words it actually commits, so a base of `#000000` names itself on hover.
|
||||
pub(crate) fn inspector_label(&self) -> String {
|
||||
let hex = |c: &[f32; 3]| {
|
||||
let b = c.map(|v| (v * 255.0).round().clamp(0.0, 255.0) as u8);
|
||||
format!("#{:02x}{:02x}{:02x}", b[0], b[1], b[2])
|
||||
};
|
||||
let hex = |c: &[f32; 3]| hex_word(*c);
|
||||
match self {
|
||||
PropLight::Exterior => "sky-lit".into(),
|
||||
PropLight::Interior {
|
||||
@@ -73,6 +70,14 @@ impl PropLight {
|
||||
}
|
||||
}
|
||||
|
||||
/// A committed light word (0–1 RGB) as `#rrggbb` — the reading every prop inspector prints, here
|
||||
/// once so the terrain lane's label and the WMO-gameobject lane's (`entities::wmo_props`) cannot
|
||||
/// drift into two different renderings of the same number.
|
||||
pub fn hex_word(c: [f32; 3]) -> String {
|
||||
let b = c.map(|v| (v * 255.0).round().clamp(0.0, 255.0) as u8);
|
||||
format!("#{:02x}{:02x}{:02x}", b[0], b[1], b[2])
|
||||
}
|
||||
|
||||
/// One MOLR-referenced light as the interior fold consumes it (world Bevy space, colour
|
||||
/// pre-multiplied by the authored intensity). Shared by the MODD prop spawn fold and the
|
||||
/// GameObject footprint lane ([`crate::interior`] via [`crate::wmo_portal`]'s verdict).
|
||||
|
||||
Reference in New Issue
Block a user