six lanes stop paying for the idle crowd, and Print Screen lands
- Print Screen takes a screenshot to `benilla-config/Screenshots/` (F13 on macOS), shows the reference's "Screen Captured" line, and the line never appears in the file — the capture defers one frame so the hidden confirmation's quads rebuild first; and the rule it surfaced is now hard law: benilla never writes anything into the WoW install - six per-frame taxes now skip when their inputs are settled: water foam asks a spatial index (a dry unit is one hash miss, was a ~2.2k-surface scan per unit), an idle remote stops its capsule sweeps at the fixed point, hover picks stop allocating ahead of the ray-sphere reject, an unchanged unit's loadout is not re-derived, and the ground-shade walk stops re-asserting the world — settled inputs produce bitwise-identical outputs, each lane fenced by its own test; ms pricing still waits on a quiet machine - overhead names take the V-plates' 20-yard cap, read at the bytes (this one is wrong and reverts in the next snapshot — the law belongs to the other nameplate system) - the calendar lifts out of the `date()` global into its own module Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
<Ui>
|
||||
<!--
|
||||
Print screen's UI half (decision 1487) — the centred "Screen Captured" line, and the
|
||||
`TakeScreenshot()` the SCREENSHOT binding runs.
|
||||
|
||||
WHAT THE REFERENCE DOES, and why this file's ordering is the whole feature. Its
|
||||
Bindings.xml maps SCREENSHOT to `TakeScreenshot()`; that function hides this frame, THEN
|
||||
asks the engine to capture; the engine answers later with SCREENSHOT_SUCCEEDED or
|
||||
SCREENSHOT_FAILED, and only then does the text appear. So the message can never be in the
|
||||
picture, on either path:
|
||||
|
||||
· the ordinary press — the outcome event lands frames after the readback, because the
|
||||
readback is asynchronous (`crate::screenshot`'s writer reports through a channel);
|
||||
· the double press inside the 1.5 s fade — the PREVIOUS shot's text is still on screen,
|
||||
and the Hide() below is what takes it off before the shutter.
|
||||
|
||||
That second case is the one worth naming: it is the only way a "Screen Captured" could ever
|
||||
have reached a file, and it is why `TakeScreenshot` hides rather than just letting the fade
|
||||
run out. B261 asked for exactly this contract.
|
||||
|
||||
PROVENANCE. The mechanism, the globals' names, the 1.5 s fade and the two strings are the
|
||||
reference's (its WorldFrame.xml/.lua carry them; the strings are GlobalStrings.lua l.3395-6,
|
||||
quoted as data like every other user-visible byte in this tree). The code is ours: the
|
||||
reference recomputes elapsed from `GetTime() - this.startTime` on every tick and ignores the
|
||||
elapsed its OnUpdate is handed — we accumulate the handed value instead, which is one clock
|
||||
read fewer per frame and one field fewer on the frame.
|
||||
|
||||
SEATING. `parent="UIParent" setAllPoints="true"` with a CENTER-anchored string, which is
|
||||
AutoFollowStatus's seat in ZoneText.xml and for the same reason: the reference parents this
|
||||
to WorldFrame, benilla's WorldFrame is a stub (UIParent.xml) rather than the 3D layer, and
|
||||
both are full-screen in the reference so their CENTERs coincide. TOOLTIP strata is the
|
||||
reference's own — the confirmation has to be readable over whatever panel is open.
|
||||
-->
|
||||
<Script>
|
||||
<![CDATA[
|
||||
-- The two lines this frame can show, byte-exact from the 1.12 enUS GlobalStrings
|
||||
-- (interface.MPQ `GlobalStrings.lua`, line-cited) — user-visible text, so its bytes are the
|
||||
-- contract. Note the capital C: it is "Screen Captured", not "Screen captured".
|
||||
SCREENSHOT_SUCCESS = "Screen Captured"; -- GlobalStrings.lua:3396
|
||||
SCREENSHOT_FAILURE = "Screen Capture Failed";-- GlobalStrings.lua:3395
|
||||
|
||||
-- Seconds the line takes to fade from full to gone. The reference's own value, and a
|
||||
-- GLOBAL there too — an addon may read or retune it, so it keeps the name.
|
||||
SCREENSHOT_STATUS_FADETIME = 1.5;
|
||||
|
||||
-- The SCREENSHOT binding's body. Hide first, capture second: see the header.
|
||||
function TakeScreenshot()
|
||||
if ( ScreenshotStatus:IsVisible() ) then
|
||||
ScreenshotStatus:Hide();
|
||||
end
|
||||
Screenshot();
|
||||
end
|
||||
|
||||
function ScreenshotStatus_OnLoad()
|
||||
this:RegisterEvent("SCREENSHOT_SUCCEEDED");
|
||||
this:RegisterEvent("SCREENSHOT_FAILED");
|
||||
end
|
||||
|
||||
function ScreenshotStatus_OnEvent(event)
|
||||
if ( event == "SCREENSHOT_FAILED" ) then
|
||||
ScreenshotStatusText:SetText(SCREENSHOT_FAILURE);
|
||||
else
|
||||
ScreenshotStatusText:SetText(SCREENSHOT_SUCCESS);
|
||||
end
|
||||
-- The fade restarts from full on every outcome, so a second shot re-announces itself
|
||||
-- at full strength instead of inheriting whatever alpha the last one had faded to.
|
||||
this.fade = 0;
|
||||
this:SetAlpha(1.0);
|
||||
this:Show();
|
||||
end
|
||||
|
||||
function ScreenshotStatus_OnUpdate(elapsed)
|
||||
local fade = (this.fade or 0) + (elapsed or 0);
|
||||
this.fade = fade;
|
||||
if ( fade >= SCREENSHOT_STATUS_FADETIME ) then
|
||||
this:Hide();
|
||||
return;
|
||||
end
|
||||
this:SetAlpha(1.0 - fade / SCREENSHOT_STATUS_FADETIME);
|
||||
end
|
||||
]]>
|
||||
</Script>
|
||||
|
||||
<Frame name="ScreenshotStatus" parent="UIParent" setAllPoints="true" frameStrata="TOOLTIP" hidden="true">
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
<FontString name="ScreenshotStatusText" inherits="SystemFont">
|
||||
<Anchors>
|
||||
<Anchor point="CENTER" relativeTo="UIParent">
|
||||
<Offset>
|
||||
<AbsDimension x="0" y="0"/>
|
||||
</Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
ScreenshotStatus_OnLoad();
|
||||
</OnLoad>
|
||||
<OnEvent>
|
||||
ScreenshotStatus_OnEvent(event);
|
||||
</OnEvent>
|
||||
<OnUpdate>
|
||||
ScreenshotStatus_OnUpdate(arg1);
|
||||
</OnUpdate>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Ui>
|
||||
@@ -132,6 +132,11 @@ MinimapCluster.xml
|
||||
# TOC loads GameTime.xml right after Minimap.xml. After MinimapCluster (its parent) and
|
||||
# GameTooltip (the hover tooltip); driven by the app's game clock (`minimap::feed_game_time`).
|
||||
GameTime.xml
|
||||
# Print screen's UI half (decision 1487): the centred "Screen Captured"/"Screen Capture Failed"
|
||||
# line, and the `TakeScreenshot()` the SCREENSHOT binding runs. Needs Fonts.xml (SystemFont) and
|
||||
# UIParent.xml (its seat) — both far above — and nothing else; seated beside ZoneText.xml, whose
|
||||
# AutoFollowStatus is the same full-screen-frame-with-a-centred-line shape.
|
||||
ScreenshotStatus.xml
|
||||
# The zone-entry splash (decision 0287): ZoneTextFrame/SubZoneTextFrame + the inlined
|
||||
# FadingFrame kit, driven by the ZONE_CHANGED family `crate::area` fires. Needs only
|
||||
# Fonts (ZoneTextFont/SubZoneTextFont) + UIParent; kept by the minimap cluster — the
|
||||
|
||||
@@ -205,6 +205,18 @@ pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
|
||||
NumpadDecimal => "NUMPADDECIMAL",
|
||||
NumLock => "NUMLOCK",
|
||||
PrintScreen => "PRINTSCREEN",
|
||||
// **On a Mac keyboard the print-screen key IS F13, and the reference says so itself.**
|
||||
// 1.12 ships `KEY_PRINTSCREEN_MAC = "F13"` beside a whole family of Mac key-label
|
||||
// overrides (GlobalStrings.lua:2476-2526 — Backspace→Delete, Insert→Help, NumLock→Clear,
|
||||
// Pause→F15, ScrollLock→F14), which is the Mac client mapping its physical keys onto the
|
||||
// PC binding TOKENS and relabelling them for display. macOS has no PrintScreen keycode at
|
||||
// all — a PC keyboard's PrtSc arrives as F13 — so without this the faithful `PRINTSCREEN`
|
||||
// default is a dead binding on every Mac, which is not fidelity, it is a broken key.
|
||||
// `_MAC` is a label table, so this is the mapping the reference implies rather than one it
|
||||
// was read out of; the falsifier is cheap (bind F13 in the Keybindings page — it must
|
||||
// write `PRINTSCREEN`, not `F13`).
|
||||
#[cfg(target_os = "macos")]
|
||||
F13 => "PRINTSCREEN",
|
||||
// ScrollLock and Pause are deliberately absent: the reference's namer calls their key
|
||||
// codes (`0x210`/`0x211`) `UNKNOWN`, so they are unbindable there (wow-re
|
||||
// `keybinding-dispatch-law.md` §2.3), and `IsValidBindingKeyString` would refuse the
|
||||
@@ -230,6 +242,12 @@ pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
|
||||
pub(crate) fn normalize_key(k: KeyCode) -> KeyCode {
|
||||
match k {
|
||||
KeyCode::NumpadEnter => KeyCode::Enter,
|
||||
// The Mac print-screen key — see [`key_token`]'s arm for the evidence. Folded here as well
|
||||
// as there because the two arms serve different halves: `key_token` names the key the
|
||||
// capture arm just swallowed, this one makes a press DISPATCH against a chord parsed from
|
||||
// the token. Only both together make `PRINTSCREEN` a working binding on a Mac.
|
||||
#[cfg(target_os = "macos")]
|
||||
KeyCode::F13 => KeyCode::PrintScreen,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
@@ -480,6 +498,29 @@ mod tests {
|
||||
assert_eq!(token_key("ENTER"), Some(BindKey::Key(KeyCode::Enter)));
|
||||
}
|
||||
|
||||
/// The Mac print-screen fold (decision 1487). macOS delivers no `PrintScreen` at all — a PC
|
||||
/// keyboard's PrtSc arrives as F13 — so BOTH arms have to agree that F13 *is* `PRINTSCREEN`,
|
||||
/// or the shipped `PRINTSCREEN SCREENSHOT` default is a key nobody on a Mac can press.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn f13_is_print_screen_on_a_mac() {
|
||||
assert_eq!(
|
||||
key_token(KeyCode::F13),
|
||||
Some("PRINTSCREEN"),
|
||||
"the capture arm"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_key(KeyCode::F13),
|
||||
KeyCode::PrintScreen,
|
||||
"the dispatch arm"
|
||||
);
|
||||
assert_eq!(
|
||||
token_key("PRINTSCREEN"),
|
||||
Some(BindKey::Key(normalize_key(KeyCode::F13))),
|
||||
"a chord parsed from the shipped default matches an F13 press"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chords_parse_with_the_112_prefix_order_and_punctuation_bases() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//!
|
||||
//! **Honest tree**: a command appears here only over a real engine action — the same law as the
|
||||
//! options rows (0954). The 1.12 commands with no benilla mechanism yet (pitch keys, walk toggle,
|
||||
//! action pages, the right multibars' MULTIACTIONBAR3/4, camera views, screenshot, combat log, …)
|
||||
//! action pages, the right multibars' MULTIACTIONBAR3/4, camera views, combat log, …)
|
||||
//! are absent, not stubbed; the page shows only what's here, and only non-empty categories (era
|
||||
//! law). Labels/headers are the 1.12
|
||||
//! GlobalStrings (`BINDING_NAME_*`/`BINDING_HEADER_*`), defined in the window's XML.
|
||||
@@ -98,8 +98,8 @@ pub(crate) mod cmd {
|
||||
// `the_cmd_handles_index_their_rows` is what makes that loud and cheap instead of silent: it
|
||||
// names the handle and the row it actually points at.
|
||||
pub(crate) const TOGGLE_UI: Cmd = Cmd(88);
|
||||
pub(crate) const CAMERA_ZOOM_IN: Cmd = Cmd(89);
|
||||
pub(crate) const CAMERA_ZOOM_OUT: Cmd = Cmd(90);
|
||||
pub(crate) const CAMERA_ZOOM_IN: Cmd = Cmd(90);
|
||||
pub(crate) const CAMERA_ZOOM_OUT: Cmd = Cmd(91);
|
||||
}
|
||||
|
||||
/// The registry, 1.12 `Bindings.xml` order. Sub-tables (action buttons, shapeshift, raid
|
||||
@@ -721,6 +721,25 @@ pub(crate) static SPECS: &[Spec] = &[
|
||||
// descend from one profile whose TOGGLEUI had been rebound; ALT-Z is the shipped default
|
||||
// (the one command whose default does NOT trust the cache file).
|
||||
spec!("TOGGLEUI", MISC, Kind::Host, Some("ALT-Z"), None),
|
||||
// Print screen (decision 1487). The body is the reference's own one-liner because our
|
||||
// `TakeScreenshot` has the same contract its does (ScreenshotStatus.xml) — hide the last
|
||||
// shot's confirmation, then ask the engine.
|
||||
//
|
||||
// `PRINTSCREEN` comes from the SHIPPED default, not a player's cache: `DefaultBindings.wtf`
|
||||
// lives inside `patch.MPQ` and its line 128 is `bind PRINTSCREEN SCREENSHOT` (wow-re's
|
||||
// dispatch on this feature; the install's account-ONE `bindings-cache.wtf` agrees). `Edge`,
|
||||
// not `EdgeUpDown`, is also byte-real: the `<Binding>` carries no `runOnUp`, and the
|
||||
// reference's dispatcher returns on key-up unless that flag is set (`0x4b7bea`).
|
||||
//
|
||||
// On a Mac keyboard the token arrives as F13, which is the reference's own Mac mapping rather
|
||||
// than an accommodation (`KEY_PRINTSCREEN_MAC = "F13"`); `super::chord` does the translation.
|
||||
spec!(
|
||||
"SCREENSHOT",
|
||||
MISC,
|
||||
Kind::Edge("TakeScreenshot();"),
|
||||
Some("PRINTSCREEN"),
|
||||
None
|
||||
),
|
||||
// ── Camera (BINDING_HEADER_CAMERA) ──────────────────────────────────────────────────
|
||||
spec!(
|
||||
"CAMERAZOOMIN",
|
||||
|
||||
@@ -204,6 +204,13 @@ fn probe_key_by_name(name: &str) -> Option<KeyCode> {
|
||||
// membership had to be found by the director, twice. `WOW_CHAR` is a deliberate one-shot
|
||||
// (`run_mode`), so synthesizing the keypress is the only way back into the world in-process.
|
||||
"Enter" => KeyCode::Enter,
|
||||
// Print screen (decision 1487). The whole player path — key → SCREENSHOT binding →
|
||||
// `TakeScreenshot()` → `Screenshot()` → the writer → SCREENSHOT_SUCCEEDED — is reachable
|
||||
// no other way: a Lua chunk can call the verb but skips the binding, and B261's contract
|
||||
// ("the message is not in the file") is a property of the KEY press, since the whole
|
||||
// reason `TakeScreenshot` hides the status line is a second press landing on a live one.
|
||||
// On macOS this arrives as F13 (`bindings::chord`'s fold) and the token is the same.
|
||||
"PrintScreen" => KeyCode::PrintScreen,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,17 +99,39 @@ fn resolve_glow(
|
||||
visual
|
||||
}
|
||||
|
||||
/// The resolve inputs that live OUTSIDE the unit's own descriptor and the global caches — the
|
||||
/// settled sheath byte, the ceremony's per-arm visual state, and the nock lane. Compared each
|
||||
/// frame by [`resolve_equipment`]'s skip gate (1490): a unit whose descriptor tick, key, and
|
||||
/// cache epochs all held still resolves to the same loadout **by construction** — every other
|
||||
/// read in the rebuild is either a field of the store or a static-after-load catalog
|
||||
/// (`ItemDisplays::catalog`, the enchant rows; the `models` map is write-only from here) — so
|
||||
/// the rebuild (the template probes, the 7-slot enchant scans, the quiver bag walk, the
|
||||
/// ensure/glow calls) is skipped whole. The output diff below stays as the last fence: this
|
||||
/// gate may only ever skip work, never change what lands.
|
||||
#[derive(Component, Clone, Copy, PartialEq)]
|
||||
pub(in crate::entities) struct ResolveKey {
|
||||
committed_sheath: u8,
|
||||
visual_sheath: Option<[u8; 2]>,
|
||||
nocked: Option<u32>,
|
||||
latched: bool,
|
||||
}
|
||||
|
||||
/// Resolve every unit's held items from its descriptor. Creatures read display/invType/sheath straight
|
||||
/// from the virtual-item fields; players go visible-item entry → [`crate::items::Items`] (ask-once query on
|
||||
/// a miss). Ensures each needed display id has a [`DisplayModel`] entry in [`ItemDisplays`] (built
|
||||
/// by [`super::update_display_models`] once the asset loads) and writes [`HeldItems`] on change.
|
||||
///
|
||||
/// **Skip-gated per unit** ([`ResolveKey`]): the full rebuild runs only when the unit's own
|
||||
/// descriptor changed, its key changed, or a global input moved — the [`Items`] epochs (an
|
||||
/// object ingest covers the quiver bag walk, a landed template answers every pending ask) and
|
||||
/// the two client-data load edges. The idle crowd costs one tick check and one small compare.
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
pub(in crate::entities) fn resolve_equipment(
|
||||
mut commands: Commands,
|
||||
units: Query<(
|
||||
Entity,
|
||||
&NetEntity,
|
||||
&ObjectStore,
|
||||
Ref<ObjectStore>,
|
||||
Option<&HeldItems>,
|
||||
Option<&Wielded>,
|
||||
Option<&Equipment>,
|
||||
@@ -117,6 +139,7 @@ pub(in crate::entities) fn resolve_equipment(
|
||||
Option<&crate::creature_anim::AnimDriver>,
|
||||
Option<&NockedAmmo>,
|
||||
Has<NockLatch>,
|
||||
Option<&ResolveKey>,
|
||||
)>,
|
||||
held: Option<ResMut<ItemDisplays>>,
|
||||
mut templates: ResMut<Items>,
|
||||
@@ -130,10 +153,19 @@ pub(in crate::entities) fn resolve_equipment(
|
||||
// The enchant column rides its own resource (decision 0915) — shared with the tooltip lane.
|
||||
glows: Option<ResMut<ItemGlows>>,
|
||||
enchants: Option<Res<crate::items::Enchants>>,
|
||||
// The [`Items`] epochs as of the last run — the skip gate's global half. Deliberately the
|
||||
// explicit counters and never resource change ticks: `templates` is `ResMut` in this very
|
||||
// system (ask-once misses write it), so a tick gate would read its own writes and never
|
||||
// close.
|
||||
mut last_epochs: Local<Option<(u64, u64)>>,
|
||||
) {
|
||||
let Some(mut held) = held else {
|
||||
return;
|
||||
};
|
||||
let epochs = (templates.object_epoch(), templates.template_epoch());
|
||||
let caches_moved = last_epochs.replace(epochs) != Some(epochs)
|
||||
|| creatures.as_ref().is_some_and(|c| c.is_changed())
|
||||
|| enchants.as_ref().is_some_and(|e| e.is_changed());
|
||||
let mut glows = glows;
|
||||
let enchant_rows = enchants.as_deref().map(|e| &e.0);
|
||||
for (
|
||||
@@ -147,12 +179,32 @@ pub(in crate::entities) fn resolve_equipment(
|
||||
driver,
|
||||
nocked,
|
||||
nock_latched,
|
||||
current_key,
|
||||
) in &units
|
||||
{
|
||||
if !matches!(net_entity.kind, EntityKind::Unit | EntityKind::Player) {
|
||||
continue;
|
||||
}
|
||||
let s = &store.0;
|
||||
// The settled sheath state: the anim layer's **client-side committed state** (the
|
||||
// setter/reconcile cache, decision 0080 — the descriptor byte plus the policy's forces);
|
||||
// else, before the driver first runs, the raw descriptor byte.
|
||||
let committed = driver
|
||||
.and_then(|d| d.sheath_state())
|
||||
.or_else(|| s.unit_sheath_state())
|
||||
.unwrap_or(0);
|
||||
let key = ResolveKey {
|
||||
committed_sheath: committed,
|
||||
visual_sheath: visual_sheath.map(|v| v.0),
|
||||
nocked: nocked.map(|n| n.display_id),
|
||||
latched: nock_latched,
|
||||
};
|
||||
if !caches_moved && !store.is_changed() && current_key == Some(&key) {
|
||||
continue;
|
||||
}
|
||||
if current_key != Some(&key) {
|
||||
commands.entity(entity).insert(key);
|
||||
}
|
||||
// The two **equipment-display preferences** (decision 1472, B123): `PLAYER_FLAGS`'
|
||||
// `HIDE_HELM 0x400` / `HIDE_CLOAK 0x800`, read off THIS unit's own descriptor. The field is
|
||||
// public, so this is per rendered body and not a local setting — a remote player who hides
|
||||
@@ -216,14 +268,8 @@ pub(in crate::entities) fn resolve_equipment(
|
||||
commands.entity(entity).insert(eq);
|
||||
}
|
||||
}
|
||||
// The settled sheath state: the anim layer's **client-side committed state** (the
|
||||
// setter/reconcile cache, decision 0080 — the descriptor byte plus the policy's forces);
|
||||
// else, before the driver first runs, the raw descriptor byte.
|
||||
let committed = driver
|
||||
.and_then(|d| d.sheath_state())
|
||||
.or_else(|| s.unit_sheath_state())
|
||||
.unwrap_or(0);
|
||||
// …and the *visual* one governing a given slot's placement, which during a draw/stow
|
||||
// …and the *visual* sheath governing a given slot's placement (`committed` was read
|
||||
// into the key above), which during a draw/stow
|
||||
// ceremony is **per arm** ([`VisualSheath`]): each hand's weapon moves at its own clip's
|
||||
// authored $SHL/$SHR moment, not at the byte change. A melee → ranged toggle therefore
|
||||
// has the sword already on the back while the bow is still on its way to the other hand —
|
||||
@@ -657,4 +703,66 @@ mod tests {
|
||||
assert_eq!((cloak_only.helm, cloak_only.cloak), (900, 0));
|
||||
assert!(helm_attached);
|
||||
}
|
||||
|
||||
/// **The skip gate** (1490): a unit whose descriptor tick, [`super::ResolveKey`] and cache
|
||||
/// epochs held still is not rebuilt at all. Proven the sharp way: a descriptor edit smuggled
|
||||
/// past change detection — a thing the wire can never do — must NOT land, because the only
|
||||
/// system that could land it skipped; the same edit under a normal (marked) touch lands on
|
||||
/// the next pass. The control half is what guards against the gate ever wrongly holding.
|
||||
#[test]
|
||||
fn an_unchanged_unit_is_not_rebuilt_and_a_real_change_still_lands() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()));
|
||||
let mut items = Items::default();
|
||||
items.insert_template(100, Some(worn(900, 1))); // INVTYPE_HEAD
|
||||
items.insert_template(101, Some(worn(901, 1))); // the swap target
|
||||
let (tx, rx) = crossbeam_channel::unbounded::<ClientCommand>();
|
||||
app.insert_resource(items);
|
||||
app.insert_resource(NetCommands(tx));
|
||||
app.insert_resource(ItemDisplays::icons_for_tests(
|
||||
benilla_formats::ItemDisplayCatalog::from_displays(std::collections::HashMap::new()),
|
||||
));
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
NetEntity {
|
||||
kind: EntityKind::Player,
|
||||
display_id: Some(49),
|
||||
scale: 1.0,
|
||||
},
|
||||
wearing(0, &[(0, 100)]),
|
||||
))
|
||||
.id();
|
||||
app.add_systems(Update, resolve_equipment);
|
||||
app.update(); // resolve (caches_moved: first run)
|
||||
app.update(); // steady state — this frame already skips
|
||||
assert_eq!(app.world().get::<Equipment>(player).unwrap().helm, 900);
|
||||
|
||||
// The smuggled edit: helm entry 100 → 101 with the change tick left untouched.
|
||||
app.world_mut()
|
||||
.get_mut::<ObjectStore>(player)
|
||||
.unwrap()
|
||||
.bypass_change_detection()
|
||||
.0
|
||||
.merge(ObjectFields::from_pairs(&[(258 + 2, 101)]));
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().get::<Equipment>(player).unwrap().helm,
|
||||
900,
|
||||
"an unmarked store must not be rebuilt — the gate held"
|
||||
);
|
||||
|
||||
// The wire's shape: the same store, now MARKED changed — the swap lands.
|
||||
app.world_mut()
|
||||
.get_mut::<ObjectStore>(player)
|
||||
.unwrap()
|
||||
.set_changed();
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().get::<Equipment>(player).unwrap().helm,
|
||||
901,
|
||||
"a marked store rebuilds — the gate opens"
|
||||
);
|
||||
drop(rx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ mod probe_shield;
|
||||
mod quest_markers;
|
||||
mod raid_marks;
|
||||
mod run_mode;
|
||||
mod screenshot;
|
||||
mod shaders;
|
||||
mod smart_rect;
|
||||
mod sound;
|
||||
@@ -681,7 +682,12 @@ pub fn run(build: BuildId) -> AppExit {
|
||||
// PLAYER_QUEST_LOG descriptor slots + the SMSG_QUEST_QUERY_RESPONSE template cache, and drives
|
||||
// QuestLogFrame.xml over the Era quest-log API.
|
||||
.add_plugins(UiQuestLogPlugin)
|
||||
.add_plugins(UiChatPlugin);
|
||||
.add_plugins(UiChatPlugin)
|
||||
// Print screen (decision 1487): the SCREENSHOT binding's engine half — one PNG per
|
||||
// `Screenshot()` call into `benilla-config/Screenshots/` (never the install — decision 1486),
|
||||
// answered to the UI as SCREENSHOT_SUCCEEDED/FAILED so the status text can never be in the
|
||||
// frame it announces.
|
||||
.add_plugins(screenshot::ScreenshotPlugin);
|
||||
|
||||
// Register benilla-assets' loaders AFTER `AssetPlugin` (they go into the live `AssetServer`).
|
||||
benilla_assets::register_asset_loaders(&mut app);
|
||||
|
||||
@@ -219,6 +219,23 @@ pub(crate) fn shots_path() -> Option<PathBuf> {
|
||||
home().map(|h| h.join("shots.txt"))
|
||||
}
|
||||
|
||||
/// `benilla-config/Screenshots/` — where the print-screen key writes (decisions 1486, 1487).
|
||||
///
|
||||
/// **The reference writes `Screenshots\\` inside the install and we deliberately do not.** benilla
|
||||
/// reads a WoW install; it never writes to one (decision 1486, the director's rule) — the folder is
|
||||
/// somebody else's, it is shared with the sibling RE repo on this machine, and a client that
|
||||
/// scatters its output through it makes "what here is benilla's?" unanswerable. So the reference's
|
||||
/// own folder NAME is kept, capital S and all, and only its parent moves: a player who knows where
|
||||
/// WoW put screenshots finds the same folder one level over.
|
||||
///
|
||||
/// Unlike every other resident here this is a DIRECTORY, not a file, and it is not written through
|
||||
/// [`write_atomic`] — an image is bytes, not a settings diff, and it is written once and never
|
||||
/// rewritten, so the tmp-then-rename dance buys nothing. It resolves through [`home`] like
|
||||
/// everything else, which is what makes a capture run hermetic ([`home`] answers `None`).
|
||||
pub(crate) fn screenshots_dir() -> Option<PathBuf> {
|
||||
home().map(|h| h.join("Screenshots"))
|
||||
}
|
||||
|
||||
/// Make an arbitrary realm/character name safe as one path component: anything outside
|
||||
/// `[A-Za-z0-9_]` becomes `_`, so a realm called `Hydraxian Waterlords` or one with a slash cannot
|
||||
/// escape the folder or collide with the path separator.
|
||||
@@ -335,6 +352,12 @@ mod tests {
|
||||
Some(tmp.join("benilla-config/account"))
|
||||
);
|
||||
assert_eq!(shots_path(), Some(tmp.join("benilla-config/shots.txt")));
|
||||
// The print-screen folder (decisions 1486/1487) — the one resident that is a DIRECTORY,
|
||||
// and the one whose reference lives inside the install we refuse to write to.
|
||||
assert_eq!(
|
||||
screenshots_dir(),
|
||||
Some(tmp.join("benilla-config/Screenshots"))
|
||||
);
|
||||
|
||||
// 0 · hermetic: a capture run resolves nothing, even with an override set. The account
|
||||
// name is the reason this line now matters more than it did: `config_base` had no such
|
||||
@@ -348,6 +371,11 @@ mod tests {
|
||||
"a capture reads no saved account"
|
||||
);
|
||||
assert_eq!(shots_path(), None);
|
||||
assert_eq!(
|
||||
screenshots_dir(),
|
||||
None,
|
||||
"a capture writes no player screenshots"
|
||||
);
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,12 @@ const SCALE_FLOOR: f32 = 0.2; // [0x80679c]
|
||||
const SCALE_KNEE: f32 = 4.0; // [0x8112a8]
|
||||
const SCALE_RATE: f32 = 1.5; // [0x8112ac]
|
||||
|
||||
/// The plate cull's squared comparand — the reference's `[0xc4d988] = 400.0 = 20²` yd², written
|
||||
/// once from `.rdata 0x80c448 = 20.0`, which no CVar registrar references: **hardcoded**, and
|
||||
/// not a distance shrink either (plate size never scales with range — verified by exhaustive
|
||||
/// absence). wow-re `nameplate-vkey.md` §5 / `nameplate-offscreen-cull.md` §2.
|
||||
const NAMEPLATE_RANGE_SQ: f32 = 400.0;
|
||||
|
||||
/// benilla's nameplate config (the client's `0xce8720` cvar mask, reduced to what streams today),
|
||||
/// player-settable since 0992: 1.12's own `UnitNamePlayer`/`UnitNameNPC`/`UnitNameOwn` CVars over
|
||||
/// these gates (the Options window's Nameplates page, through the 0954 store — the arms live in
|
||||
@@ -365,6 +371,9 @@ pub(crate) fn drive_nameplates(
|
||||
Option<&InheritedVisibility>,
|
||||
)>,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
// The 20-yard cap's anchor: the reference measures from the LOCAL PLAYER, not the camera
|
||||
// (`0x60f600`'s 3-D squared distance).
|
||||
self_tf: Query<&Transform, With<SelfPlayer>>,
|
||||
// The show-gate inputs (one tuple param — Bevy's 16-param ceiling).
|
||||
gates: (
|
||||
Res<Selection>,
|
||||
@@ -459,6 +468,23 @@ pub(crate) fn drive_nameplates(
|
||||
if !drawn.is_none_or(|v| v.get()) {
|
||||
continue;
|
||||
}
|
||||
// **The 20-yard cap** (wow-re `nameplate-offscreen-cull.md` §2, VERIFIED): `0x60f600`
|
||||
// destroys the frame when the 3-D squared player→unit distance exceeds
|
||||
// `[0xc4d988] = 400 = 20²` — hardcoded (`.rdata 0x80c448`, no CVar registrar ref),
|
||||
// measured from the LOCAL PLAYER, never the camera, with no line-of-sight term. Beyond
|
||||
// it the reference DESTROYS the plate rather than hiding it; skipping here keeps this
|
||||
// unit out of `seen`, and the retain sweep below IS that destroy. The reference runs
|
||||
// this gate LAST in its admission ladder (after the projection gate) — every rung is
|
||||
// verdict-independent, so hoisting it changes no outcome, only how much of the ladder
|
||||
// the far crowd pays. With no self body (a capture rig without a player) the gate
|
||||
// stands down — the reference's "no local player ⇒ destroy" rung is the fixtures' call,
|
||||
// not this one's (named residual, 1490).
|
||||
if let Ok(anchor) = self_tf.single() {
|
||||
if !is_self && tf.translation.distance_squared(anchor.translation) > NAMEPLATE_RANGE_SQ
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// The ShouldShowName gate, in the client's own order (own-unit answers its cvar BEFORE
|
||||
// the rescue; everyone else: the current-TARGET bypass — `[0xb4e2d8]` is the selection,
|
||||
// not the mouseover — then the kind cvar). A unit carrying a V-key nameplate never also
|
||||
|
||||
@@ -382,6 +382,7 @@ pub(super) fn unit_move(
|
||||
relay: Default::default(),
|
||||
last_apply_ms: now_ms,
|
||||
last_apply_pos: mv.position,
|
||||
settled_at: None,
|
||||
};
|
||||
let fire_ms = rm.relay.schedule(mv.wire_ms, now_ms, 0, true);
|
||||
debug_assert_eq!(fire_ms, now_ms, "a seeding packet fires at arrival");
|
||||
|
||||
@@ -73,6 +73,18 @@ pub(crate) struct RemoteMotion {
|
||||
/// not anything the server said. Read by the runaway watch in [`extrapolate_remote_units`].
|
||||
pub(crate) last_apply_ms: f64,
|
||||
pub(crate) last_apply_pos: [f32; 3],
|
||||
/// **The settled memo** — the position at which this mover's last world resolve was a
|
||||
/// fixed point: flag-still, grounded on a real support, and returned bitwise where it
|
||||
/// started. While the frame's pre-resolve position still equals it, the resolve is skipped
|
||||
/// whole — a standing NPC was paying a depenetration contact enumeration (heap Vec + broad
|
||||
/// phase) plus a capsule down-cast every frame for a result proven identical (1473 §3's
|
||||
/// idle-remote row; the coordinate round-trip is sign/permutation-lossless and
|
||||
/// `grounded_step` is pure in its inputs, so bitwise equality is the honest test, the
|
||||
/// `facing.rs` dead-band's shape). Anything that moves the mover — a packet apply, a
|
||||
/// direction flag, the reconcile lerp, a jump arc — changes the position or flags and
|
||||
/// misses the memo; a mover standing where ground has not streamed in never arms it
|
||||
/// (`ground` was `None`) and keeps resolving until the world exists under it.
|
||||
pub(crate) settled_at: Option<[f32; 3]>,
|
||||
}
|
||||
|
||||
/// What [`crate::net::apply`]'s `unit_move` did with an inbound relayed move — the `out=` field of
|
||||
@@ -480,7 +492,10 @@ pub(in crate::net) fn extrapolate_remote_units(
|
||||
// fight it).
|
||||
let airborne = rm.flags & move_flags::FALLING != 0;
|
||||
let afloat = rm.flags & move_flags::SWIMMING != 0;
|
||||
if !afloat && !riding && !flat_extrapolation() {
|
||||
// The settled memo ([`RemoteMotion::settled_at`]): an idle mover standing exactly where
|
||||
// its last resolve left it re-derives nothing — the resolve is skipped, `held` honestly
|
||||
// stays 0.
|
||||
if !afloat && !riding && rm.settled_at != Some(pos) && !flat_extrapolation() {
|
||||
let half_h = Vec3::Y * (crate::player::CAPSULE_HEIGHT * 0.5);
|
||||
let from = wow_to_bevy(rm.wow_pos) + half_h;
|
||||
// The frame's velocity by construction. Grounded, [`RemoteMotion::advance`] never moves
|
||||
@@ -491,10 +506,19 @@ pub(in crate::net) fn extrapolate_remote_units(
|
||||
} else {
|
||||
Vec3::ZERO
|
||||
};
|
||||
let resolved_center = if airborne {
|
||||
crate::player::mover::airborne_step(&world, &capsule.0, from, vel, time.delta())
|
||||
let (resolved_center, supported) = if airborne {
|
||||
(
|
||||
crate::player::mover::airborne_step(
|
||||
&world,
|
||||
&capsule.0,
|
||||
from,
|
||||
vel,
|
||||
time.delta(),
|
||||
),
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
crate::player::mover::grounded_step(
|
||||
let g = crate::player::mover::grounded_step(
|
||||
&world,
|
||||
&capsule.0,
|
||||
from,
|
||||
@@ -509,10 +533,16 @@ pub(in crate::net) fn extrapolate_remote_units(
|
||||
// watched player descending a steep face gets the ordinary cone reach, and the
|
||||
// next packet's wire Z corrects whatever that misses.
|
||||
crate::player::mover::Support::default(),
|
||||
)
|
||||
.center
|
||||
);
|
||||
(g.center, g.ground.is_some())
|
||||
};
|
||||
let resolved = bevy_to_wow(resolved_center - half_h);
|
||||
// Arm the memo only at the fixed point: flag-still, on real ground, and the resolve
|
||||
// handed back exactly its input (one snap frame after stopping, the second resolve
|
||||
// does — deterministically, the wow↔bevy round-trip being lossless). A miss on any
|
||||
// term keeps resolving every frame.
|
||||
rm.settled_at = (supported && rm.flags & move_flags::ANY_MOVE == 0 && resolved == pos)
|
||||
.then_some(resolved);
|
||||
held = (pos[0] - resolved[0]).hypot(pos[1] - resolved[1]);
|
||||
pos = resolved;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ fn motion(flags: u32, orientation: f32) -> RemoteMotion {
|
||||
relay: Default::default(),
|
||||
last_apply_ms: 0.0,
|
||||
last_apply_pos: [0.0, 0.0, 0.0],
|
||||
settled_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -882,3 +883,94 @@ fn a_gameobject_without_a_usable_quaternion_falls_back_to_its_facing() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **The settled memo** ([`RemoteMotion::settled_at`], 1490 item 2): an idle grounded remote
|
||||
/// arms it once its resolve reaches the fixed point, holds bitwise still while armed, keeps
|
||||
/// resolving where no ground exists, and disarms the moment a direction flag moves it — so the
|
||||
/// per-frame depenetration + down-cast run only for movers that can actually change.
|
||||
#[test]
|
||||
fn an_idle_remote_settles_and_stops_sweeping() {
|
||||
use avian3d::prelude::{Collider, RigidBody};
|
||||
use bevy::prelude::*;
|
||||
use bevy::transform::TransformPlugin;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins((
|
||||
MinimalPlugins,
|
||||
TransformPlugin,
|
||||
bevy::asset::AssetPlugin::default(),
|
||||
bevy::scene::ScenePlugin,
|
||||
avian3d::prelude::PhysicsPlugins::new(bevy::app::PostUpdate),
|
||||
));
|
||||
app.init_asset::<Mesh>()
|
||||
.init_resource::<benilla_world::collision::ColliderEpoch>();
|
||||
app.finish();
|
||||
app.cleanup();
|
||||
app.insert_resource(crate::player::PlayerCapsule(Collider::capsule(
|
||||
crate::player::CAPSULE_RADIUS,
|
||||
crate::player::CAPSULE_HEIGHT - 2.0 * crate::player::CAPSULE_RADIUS,
|
||||
)));
|
||||
app.add_systems(Update, super::remote::extrapolate_remote_units);
|
||||
// A 10×10 up-wound floor at bevy y = 0 (wow z = 0).
|
||||
let verts = vec![
|
||||
Vec3::new(-5.0, 0.0, -5.0),
|
||||
Vec3::new(5.0, 0.0, -5.0),
|
||||
Vec3::new(5.0, 0.0, 5.0),
|
||||
Vec3::new(-5.0, 0.0, 5.0),
|
||||
];
|
||||
app.world_mut().spawn((
|
||||
RigidBody::Static,
|
||||
Collider::trimesh(verts, vec![[0u32, 2, 1], [0, 3, 2]]),
|
||||
Transform::default(),
|
||||
));
|
||||
let idle = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Transform::default(),
|
||||
motion(0, 0.0),
|
||||
// Real speeds, so the FORWARD leg below actually translates — a flag with no speed
|
||||
// moves nothing and would (correctly) stay settled.
|
||||
crate::net::UnitSpeeds(speeds()),
|
||||
))
|
||||
.id();
|
||||
let mut over_nothing = motion(0, 0.0);
|
||||
over_nothing.wow_pos = [50.0, 50.0, 10.0];
|
||||
let stranded = app
|
||||
.world_mut()
|
||||
.spawn((Transform::default(), over_nothing))
|
||||
.id();
|
||||
// Seed the idle mover a hair above the floor — inside the standing snap reach (at zero
|
||||
// speed the whole reach IS `STEP_SNAP_SLACK` ≈ 0.028), like a wire Z, which always arrives
|
||||
// on the ground. The first resolve snaps it on, the second arms the memo.
|
||||
app.world_mut()
|
||||
.entity_mut(idle)
|
||||
.get_mut::<RemoteMotion>()
|
||||
.unwrap()
|
||||
.wow_pos = [0.0, 0.0, 0.01];
|
||||
|
||||
for _ in 0..4 {
|
||||
app.update();
|
||||
}
|
||||
let rm = app.world().entity(idle).get::<RemoteMotion>().unwrap();
|
||||
let armed_at = rm.settled_at.expect("an idle grounded mover arms the memo");
|
||||
assert_eq!(armed_at, rm.wow_pos, "the memo is the pose it stands at");
|
||||
let parked = rm.wow_pos;
|
||||
for _ in 0..3 {
|
||||
app.update();
|
||||
}
|
||||
let rm = app.world().entity(idle).get::<RemoteMotion>().unwrap();
|
||||
assert_eq!(rm.wow_pos, parked, "armed ⇒ bitwise still");
|
||||
assert_eq!(rm.settled_at, Some(parked), "…and still armed");
|
||||
// No ground in reach ⇒ never arms; it keeps asking until the world exists under it.
|
||||
let rm = app.world().entity(stranded).get::<RemoteMotion>().unwrap();
|
||||
assert_eq!(rm.settled_at, None, "no support ⇒ no memo");
|
||||
// A direction flag moves the mover and the memo falls with it.
|
||||
app.world_mut()
|
||||
.entity_mut(idle)
|
||||
.get_mut::<RemoteMotion>()
|
||||
.unwrap()
|
||||
.flags = move_flags::FORWARD;
|
||||
app.update();
|
||||
let rm = app.world().entity(idle).get::<RemoteMotion>().unwrap();
|
||||
assert_eq!(rm.settled_at, None, "a moving mover resolves every frame");
|
||||
}
|
||||
|
||||
@@ -79,18 +79,19 @@ pub(crate) use follow::{FollowRequest, FollowState};
|
||||
// The shared avatar state + movement constants live in [`state`]; the private re-imports below are
|
||||
// what lets this module and the concern modules beside it keep naming them `super::X` unchanged.
|
||||
use state::{
|
||||
MoveSpeed, PlayerRide, AIR_NUDGE_SPEED, CAPSULE_RADIUS, FALL_FAR_DROP, FALL_FAR_TIME,
|
||||
FOOT_CONE_HEIGHT, GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP,
|
||||
RUN_BACK_RATIO, SKIN_WIDTH, STATIONARY_CHASE_RATE, STEP_SLOPE_RATIO, STEP_SNAP_SLACK,
|
||||
STEP_UP_ADVANCE, STEP_UP_HEIGHT, TURN_RATE, TURN_RATE_MOVING, WEDGE_MIN_FALL,
|
||||
WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
|
||||
MoveSpeed, PlayerRide, AIR_NUDGE_SPEED, FALL_FAR_DROP, FALL_FAR_TIME, FOOT_CONE_HEIGHT,
|
||||
GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP, RUN_BACK_RATIO,
|
||||
SKIN_WIDTH, STATIONARY_CHASE_RATE, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_ADVANCE,
|
||||
STEP_UP_HEIGHT, TURN_RATE, TURN_RATE_MOVING, WEDGE_MIN_FALL, WEDGE_STALL_RATIO,
|
||||
WEDGE_STILL_FRAMES,
|
||||
};
|
||||
// `SETTLE_TIMEOUT` is `pub(crate)`: the settle release lives in the terrain streamer (decision
|
||||
// 0737 — residency releases the hold, not ground contact), which owns the deadline push while the
|
||||
// resident world is still the departed map's (0710).
|
||||
pub(crate) use state::{
|
||||
Player, PlayerCapsule, CAPSULE_HEIGHT, DEFAULT_COLLISION_HEIGHT, FEATHER_TERMINAL_VELOCITY,
|
||||
GRAVITY, HOVER_CLIMB_RATE, HOVER_HEIGHT, SETTLE_TIMEOUT, TERMINAL_VELOCITY,
|
||||
Player, PlayerCapsule, CAPSULE_HEIGHT, CAPSULE_RADIUS, DEFAULT_COLLISION_HEIGHT,
|
||||
FEATHER_TERMINAL_VELOCITY, GRAVITY, HOVER_CLIMB_RATE, HOVER_HEIGHT, SETTLE_TIMEOUT,
|
||||
TERMINAL_VELOCITY,
|
||||
};
|
||||
/// The swim boundary `0.75·h` — and therefore the **wade ceiling**, since wading is the implicit
|
||||
/// in-liquid-but-not-swimming state and the two cannot be different numbers. Read by the creature
|
||||
|
||||
@@ -180,6 +180,7 @@ mod tests {
|
||||
relay: Default::default(),
|
||||
last_apply_ms: 0.0,
|
||||
last_apply_pos: [0.0; 3],
|
||||
settled_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ pub(super) const STATIONARY_CHASE_RATE: f32 = 8.0;
|
||||
/// radius **0.30555**, human female **0.20835** (VERIFIED, wow-re `mover-collision-scalars.md`;
|
||||
/// decision 1125). Adopting them is the same movement-fidelity question [`CAPSULE_HEIGHT`] describes
|
||||
/// below, and this radius is its other half.
|
||||
pub(super) const CAPSULE_RADIUS: f32 = 1.0 / 3.0;
|
||||
pub(crate) const CAPSULE_RADIUS: f32 = 1.0 / 3.0;
|
||||
/// Player capsule total height (yd) — the **movement** capsule avian sweeps, deliberately a
|
||||
/// constant. Numerically the vanilla ctor-default collision height it was derived from
|
||||
/// ([`DEFAULT_COLLISION_HEIGHT`]), but it is not the same quantity and no longer stands in for one:
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//! **Print screen** (decision 1487) — the capture key, the writer, and the "Screen Captured" text
|
||||
//! that must never appear in the file it announces.
|
||||
//!
|
||||
//! The reference's shape, which this reproduces exactly (its own `Bindings.xml` + `WorldFrame.lua`,
|
||||
//! read off the 1.12.1 install):
|
||||
//!
|
||||
//! ```text
|
||||
//! PRINTSCREEN → binding SCREENSHOT → TakeScreenshot() [FrameXML]
|
||||
//! ScreenshotStatus:Hide() ← last frame's text, gone first
|
||||
//! Screenshot() [engine]
|
||||
//! … the frame is captured …
|
||||
//! engine → SCREENSHOT_SUCCEEDED / SCREENSHOT_FAILED → ScreenshotStatus shows, fades over 1.5 s
|
||||
//! ```
|
||||
//!
|
||||
//! **That ordering is most of the answer to B261's third clause, and the rest is ours to arrange.**
|
||||
//! For a single press nothing can leak: `Screenshot()` returns nothing and the outcome comes back
|
||||
//! as an *event*, frames later, because the readback is asynchronous — the confirmation does not
|
||||
//! exist yet when the shutter fires. The leak is the SECOND press inside the 1.5 s fade, when the
|
||||
//! previous shot's "Screen Captured" is still on screen; that is what the reference's `Hide()`
|
||||
//! before the capture is for.
|
||||
//!
|
||||
//! **And on our pipeline that `Hide()` lands one frame too late** — measured, not reasoned:
|
||||
//! a live double press wrote the text straight into the second PNG. `ui_script`'s `drive_script`
|
||||
//! ticks the VM and builds this frame's UI quad list at the TOP of the `UiInput` set; the binding
|
||||
//! dispatch that runs `TakeScreenshot()` sits at the BOTTOM of that same set. So by the time the
|
||||
//! binding hides the frame, the quads carrying its text are already built, and the frame that then
|
||||
//! renders — and gets captured — still has the line in it. Nothing in the reference's design says
|
||||
//! otherwise; the real client simply does not build its draw list in that order.
|
||||
//!
|
||||
//! So [`ask_for_captures`] **holds each ask for one frame** ([`ScreenshotState::pending`]). The
|
||||
//! capture is requested on the frame AFTER the press, whose `drive_script` has rebuilt the quads
|
||||
//! with the frame hidden. One frame is the whole fix and also the minimum: any less and the stale
|
||||
//! quads are what gets photographed, any more and the shutter drifts from the keypress for no
|
||||
//! reason. `ui_script::screenshot_tests` pins the UI contract; this file's own test pins the
|
||||
//! deferral, and the live falsifier is a double press 0.6 s apart (decision 1487).
|
||||
//!
|
||||
//! **Two deliberate divergences, both recorded in 1487:**
|
||||
//!
|
||||
//! 1 · **PNG, where the reference writes TGA.** An uncompressed 32-bit Targa of a modern window is
|
||||
//! ~8 MB and no ordinary tool on a 2026 desktop previews one. This is the "mandatory unfaithful
|
||||
//! change" the report came in with; the mechanism, the folder and the naming are otherwise the
|
||||
//! reference's.
|
||||
//! 2 · **`benilla-config/Screenshots/`, not `<install>/Screenshots/`** — decision 1486's rule:
|
||||
//! benilla reads a WoW install and never writes to one. Same folder name, different parent.
|
||||
//!
|
||||
//! **One reference behaviour deliberately NOT transcribed.** The real client fires
|
||||
//! `SCREENSHOT_SUCCEEDED` whether or not the file was written — it discards the writer's return
|
||||
//! value, so the event means "the device gave us a buffer" — and it has already deleted the
|
||||
//! partial file. That is a bug, not a contract: [`report_captures`] answers `SCREENSHOT_FAILED`
|
||||
//! when the encode or the write actually failed, and puts the reason in the log.
|
||||
//!
|
||||
//! **Two gaps that ARE faithful**, named so they are not mistaken for oversights: a raw
|
||||
//! `Screenshot()` from a macro or addon skips `TakeScreenshot`'s `Hide()` and so can photograph a
|
||||
//! fading confirmation (the reference's hide is in Lua too, and inventing an engine-side one would
|
||||
//! be inventing behaviour); and the key does nothing at the glue screens, where the reference
|
||||
//! captures through hard-coded, unrebindable `OnKeyDown` handlers that print nothing — a separate
|
||||
//! slice, in the glue arc.
|
||||
//!
|
||||
//! Nothing here is dev-gated: this is a player feature, and it links in the `--no-default-features`
|
||||
//! player build like the rest of the UI arc.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::view::screenshot::{Screenshot, ScreenshotCaptured};
|
||||
use bevy::tasks::IoTaskPool;
|
||||
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use crate::ui_script::UiInput;
|
||||
|
||||
/// The reference's own file stem (`WoWScrnShot_`), kept: a player who knows what WoW screenshots
|
||||
/// are called finds the same names here, and so does every screenshot-organizing tool the
|
||||
/// community has. Only the extension changes.
|
||||
const STEM: &str = "WoWScrnShot";
|
||||
|
||||
/// What the writer thread reported back. The outcome is a two-state answer because the UI's is:
|
||||
/// `SCREENSHOT_SUCCEEDED` or `SCREENSHOT_FAILED`, nothing in between.
|
||||
enum Outcome {
|
||||
Saved(std::path::PathBuf),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// The capture arc's state: the channel the writer threads report through, and just enough clock
|
||||
/// memory to keep two captures in the same second from landing on the same name.
|
||||
#[derive(Resource)]
|
||||
struct ScreenshotState {
|
||||
tx: crossbeam_channel::Sender<Outcome>,
|
||||
rx: crossbeam_channel::Receiver<Outcome>,
|
||||
/// The epoch second the last name was minted for, and how many have been minted for it.
|
||||
/// **Held here rather than probed off the filesystem**: the write happens on a worker thread
|
||||
/// and may not have created the file by the time the next name is needed, so "does it exist
|
||||
/// yet?" is a race that silently overwrites. A counter on the main thread cannot lose.
|
||||
last_second: i64,
|
||||
within_second: u32,
|
||||
/// Captures asked for on the PREVIOUS frame, waiting for this one to render. The one-frame
|
||||
/// hold that keeps the status line out of the picture — see the module docs for the measured
|
||||
/// reason it is needed at all.
|
||||
pending: u32,
|
||||
}
|
||||
|
||||
impl Default for ScreenshotState {
|
||||
fn default() -> Self {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
Self {
|
||||
tx,
|
||||
rx,
|
||||
last_second: i64::MIN,
|
||||
within_second: 0,
|
||||
pending: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScreenshotState {
|
||||
/// The next file name, from the wall clock: `WoWScrnShot_MMDDYY_HHMMSS.png`, the reference's
|
||||
/// own field order and zero padding.
|
||||
///
|
||||
/// **A second capture inside the same second gets `_2`, `_3`, … rather than overwriting.**
|
||||
/// That is a divergence and a deliberate one: the reference's fixed-name write means a burst
|
||||
/// of key presses leaves one file, which is a silent data loss the player only discovers
|
||||
/// later. The suffix is outside the reference's grammar, so nothing that parses the faithful
|
||||
/// name is broken by a name it will simply not match.
|
||||
///
|
||||
/// **UTC, not local time** — the reference stamps local. The workspace carries no date
|
||||
/// dependency and a local offset needs a timezone source, not different arithmetic; the Lua
|
||||
/// `date()` global records the same divergence for the same reason
|
||||
/// ([`benilla_ui::civil`]). Names stay unique and sort correctly either way.
|
||||
fn next_name(&mut self, now: i64) -> String {
|
||||
if now == self.last_second {
|
||||
self.within_second += 1;
|
||||
} else {
|
||||
self.last_second = now;
|
||||
self.within_second = 0;
|
||||
}
|
||||
let c = benilla_ui::civil::from_unix(now);
|
||||
let base = format!(
|
||||
"{STEM}_{:02}{:02}{:02}_{:02}{:02}{:02}",
|
||||
c.month,
|
||||
c.day,
|
||||
c.year.rem_euclid(100),
|
||||
c.hour,
|
||||
c.min,
|
||||
c.sec
|
||||
);
|
||||
match self.within_second {
|
||||
0 => format!("{base}.png"),
|
||||
n => format!("{base}_{}.png", n + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the renderer for one frame per queued `Screenshot()` call — **one frame after the call**.
|
||||
///
|
||||
/// The order within a frame is: spawn last frame's asks first, then drain this frame's. Both halves
|
||||
/// matter. Spawning first means the entity is in the world before the render extract at the end of
|
||||
/// THIS frame, whose UI quads `drive_script` rebuilt with the status line already hidden. Draining
|
||||
/// second means an ask made by the binding dispatch earlier in this same frame waits its turn
|
||||
/// rather than being photographed against the quads that were built before the hide — which is the
|
||||
/// bug this deferral exists for (module docs).
|
||||
///
|
||||
/// Ordered `.after(BindingSet)` so the drain deterministically sees an ask the binding just made,
|
||||
/// instead of depending on where an unconstrained system happened to land.
|
||||
fn ask_for_captures(
|
||||
mut commands: Commands,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut state: ResMut<ScreenshotState>,
|
||||
) {
|
||||
for _ in 0..std::mem::take(&mut state.pending) {
|
||||
commands
|
||||
.spawn(Screenshot::primary_window())
|
||||
.observe(write_capture);
|
||||
}
|
||||
if let Some(mut script) = script {
|
||||
state.pending += script.take_screenshot_asks();
|
||||
}
|
||||
}
|
||||
|
||||
/// The readback landed: name the file and hand the encode+write to a worker.
|
||||
///
|
||||
/// **Off the main thread on purpose.** A 2560×1440 PNG encode is tens of milliseconds and this
|
||||
/// runs inside the frame; doing it here would drop frames every time the player takes a shot,
|
||||
/// which is the one moment they are looking at the picture. The answer comes back through the
|
||||
/// channel and is reported on whichever later frame it arrives — the UI already expects that,
|
||||
/// because the reference's outcome is an event too.
|
||||
fn write_capture(captured: On<ScreenshotCaptured>, mut state: ResMut<ScreenshotState>) {
|
||||
// Nothing to write to: a hermetic capture/probe run (`$WOW_CAPTURE`), or a platform with no
|
||||
// discoverable exe directory. Report the failure rather than dropping it silently — the UI
|
||||
// says "Screen Capture Failed" and the player learns something.
|
||||
let Some(dir) = crate::local_state::screenshots_dir() else {
|
||||
let _ = state.tx.send(Outcome::Failed(
|
||||
"no benilla-config folder to write into".into(),
|
||||
));
|
||||
return;
|
||||
};
|
||||
let path = dir.join(state.next_name(benilla_ui::civil::unix_seconds()));
|
||||
|
||||
// `to_rgb8` drops alpha, which on an HDR target carries brightness rather than opacity — the
|
||||
// same choice bevy's own `save_to_disk` makes, and for the same reason: kept, the picture is
|
||||
// wrong.
|
||||
let dynamic = match captured.image.clone().try_into_dynamic() {
|
||||
Ok(img) => img.to_rgb8(),
|
||||
Err(e) => {
|
||||
let _ = state
|
||||
.tx
|
||||
.send(Outcome::Failed(format!("unreadable frame: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let tx = state.tx.clone();
|
||||
IoTaskPool::get()
|
||||
.spawn(async move {
|
||||
let outcome = std::fs::create_dir_all(&dir)
|
||||
.and_then(|()| {
|
||||
dynamic
|
||||
.save_with_format(&path, image::ImageFormat::Png)
|
||||
.map_err(std::io::Error::other)
|
||||
})
|
||||
.map(|()| Outcome::Saved(path.clone()))
|
||||
.unwrap_or_else(|e| Outcome::Failed(format!("{}: {e}", path.display())));
|
||||
let _ = tx.send(outcome);
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Report finished writes to the UI — the reference's `SCREENSHOT_SUCCEEDED` / `SCREENSHOT_FAILED`.
|
||||
///
|
||||
/// **Before [`UiInput`]**, the shape every other feed in this crate uses: the event is delivered at
|
||||
/// the top of a frame so the status text is laid out with everything else, and — the part that
|
||||
/// matters — this can only ever be a frame *after* the one that was captured, so the text it puts
|
||||
/// on screen cannot be in the picture.
|
||||
fn report_captures(script: Option<NonSendMut<UiScript>>, state: Res<ScreenshotState>) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
while let Ok(outcome) = state.rx.try_recv() {
|
||||
match outcome {
|
||||
Outcome::Saved(path) => {
|
||||
// Announced at info: "where did my screenshot go" is the first question a player
|
||||
// asks about a folder that is deliberately not where WoW put it (decision 1486).
|
||||
info!("screenshot: wrote {}", path.display());
|
||||
script.fire_event("SCREENSHOT_SUCCEEDED", Vec::new());
|
||||
}
|
||||
Outcome::Failed(why) => {
|
||||
warn!("screenshot: FAILED — {why}");
|
||||
script.fire_event("SCREENSHOT_FAILED", Vec::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ScreenshotPlugin;
|
||||
|
||||
impl Plugin for ScreenshotPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<ScreenshotState>().add_systems(
|
||||
Update,
|
||||
(
|
||||
report_captures.before(UiInput),
|
||||
ask_for_captures
|
||||
.after(UiInput)
|
||||
.after(crate::bindings::BindingSet),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The reference's field order and padding, and the anti-clobber suffix — the two halves of
|
||||
/// [`ScreenshotState::next_name`] that a reader would otherwise have to take on trust.
|
||||
#[test]
|
||||
fn names_follow_the_reference_and_never_collide() {
|
||||
let mut s = ScreenshotState::default();
|
||||
// 2026-08-21T13:45:07Z — single digits in the day and the hour would be the padding bug.
|
||||
assert_eq!(s.next_name(1_787_319_907), "WoWScrnShot_082126_134507.png");
|
||||
// Same second again: a suffix, not an overwrite. The reference would have written the
|
||||
// same name twice and left one file.
|
||||
assert_eq!(
|
||||
s.next_name(1_787_319_907),
|
||||
"WoWScrnShot_082126_134507_2.png"
|
||||
);
|
||||
assert_eq!(
|
||||
s.next_name(1_787_319_907),
|
||||
"WoWScrnShot_082126_134507_3.png"
|
||||
);
|
||||
// A new second resets the counter — the plain name is the overwhelmingly common one.
|
||||
assert_eq!(s.next_name(1_787_319_908), "WoWScrnShot_082126_134508.png");
|
||||
// 2001-01-02T03:04:05Z: every field at its narrowest, and a year whose last two digits
|
||||
// need the leading zero the reference's `%02d` gives them.
|
||||
assert_eq!(s.next_name(978_404_645), "WoWScrnShot_010201_030405.png");
|
||||
}
|
||||
|
||||
/// **The one-frame hold, as a test** — the fix for the leak a live double press actually
|
||||
/// produced (module docs). An ask made during frame N must not be spawned until frame N+1,
|
||||
/// because frame N's UI quads were built before the binding hid the status line.
|
||||
///
|
||||
/// Driven against the real system in a minimal app rather than by calling a helper: the claim
|
||||
/// is about *scheduling*, and a helper that runs both halves in one call would pass whether or
|
||||
/// not the deferral survives a future reorder.
|
||||
#[test]
|
||||
fn an_ask_is_held_for_one_frame_before_the_shutter() {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<ScreenshotState>();
|
||||
// The observer is irrelevant here — what is under test is when the entity appears — so the
|
||||
// system runs with no VM and the asks are pushed by hand.
|
||||
app.add_systems(Update, spawn_pending_only);
|
||||
|
||||
// Frame N: the binding asked. Nothing may be spawned yet.
|
||||
app.world_mut().resource_mut::<ScreenshotState>().pending = 0;
|
||||
app.world_mut().resource_mut::<ScreenshotState>().pending += 1;
|
||||
let staged = app.world().resource::<ScreenshotState>().pending;
|
||||
assert_eq!(staged, 1, "the ask is staged, not yet a capture");
|
||||
|
||||
// Frame N+1: now it fires, against quads rebuilt with the line hidden.
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().resource::<ScreenshotState>().pending,
|
||||
0,
|
||||
"the held ask is consumed on the next frame"
|
||||
);
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&Screenshot>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1,
|
||||
"and exactly one capture is requested"
|
||||
);
|
||||
|
||||
// A quiet frame asks for nothing.
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&Screenshot>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// [`ask_for_captures`]'s spawn half alone — the drain half needs a live VM, which a bare
|
||||
/// `App` has no business building. Keeping the two in one system in production and splitting
|
||||
/// only here would be the usual lie, so this calls the real thing with the VM absent, which is
|
||||
/// exactly how it behaves at the character-select screen.
|
||||
fn spawn_pending_only(commands: Commands, state: ResMut<ScreenshotState>) {
|
||||
ask_for_captures(commands, None, state);
|
||||
}
|
||||
}
|
||||
@@ -189,8 +189,12 @@ pub(super) fn update_hover(
|
||||
continue;
|
||||
}
|
||||
let Some(children) = children else { continue };
|
||||
let skinned: Vec<_> = children.iter().filter_map(|c| parts.get(c).ok()).collect();
|
||||
if skinned.is_empty() {
|
||||
// Ownership probe only — no collect yet. The `skinned` Vec used to be built HERE, for
|
||||
// every drawn unit in the scene, ahead of the few-FLOP broad phase that rejects nearly
|
||||
// all of them (1473 §3's hover row); the survivors alone pay it now, below. Membership
|
||||
// in `faithful` must still be decided pre-reject — a broad-phase-rejected skinned unit
|
||||
// stays excluded from the AABB fallback by RULE, not by accident.
|
||||
if !children.iter().any(|c| parts.contains(c)) {
|
||||
continue; // static/cube unit → the AABB fallback below
|
||||
}
|
||||
faithful.insert(entity);
|
||||
@@ -215,6 +219,7 @@ pub(super) fn update_hover(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let skinned: Vec<_> = children.iter().filter_map(|c| parts.get(c).ok()).collect();
|
||||
// The posed joint palette (parts of one skeleton share the rig): the same
|
||||
// world-from-bind-pose matrices GPU skinning applies, read from the owned palette rows
|
||||
// (decision 0720 — last frame's propagated pose, exactly what the previous
|
||||
@@ -346,6 +351,20 @@ pub(super) fn update_hover(
|
||||
/// ties by distance. Both passes stay world-occlusion-clamped. (Residual, same slice as the
|
||||
/// eligibility note below: the reference's sticky slot is cross-type, so a stuck GO pick can
|
||||
/// outrank a unit's halo there; our split picks give the unit that frame instead.)
|
||||
/// The GO picker's own state, bundled — the pick-set cache with the stream edges that rebuild
|
||||
/// it, plus the sticky-hover memory — one [`SystemParam`] because [`update_hovered_object`]
|
||||
/// sits at Bevy's 16-param function-system ceiling. See the maintenance comment at the top of
|
||||
/// that system for the cache contract.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(super) struct GoPickSet<'w, 's> {
|
||||
added: Query<'w, 's, (), Added<GoPickPart>>,
|
||||
removed: RemovedComponents<'w, 's, GoPickPart>,
|
||||
cache: Local<'s, bevy::platform::collections::HashSet<Entity>>,
|
||||
/// Last frame's GO pick, for pass 2's sticky-hover (the reference's anti-flicker cache, by
|
||||
/// net entity — the same rung the unit picker keeps).
|
||||
last_pick: Local<'s, Option<Entity>>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn update_hovered_object(
|
||||
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
|
||||
@@ -370,10 +389,39 @@ pub(super) fn update_hovered_object(
|
||||
factions: Option<Res<super::ring::Factions>>,
|
||||
self_q: Query<&ObjectStore, With<crate::net::SelfPlayer>>,
|
||||
parts: PickParts,
|
||||
// Last frame's GO pick, for pass 2's sticky-hover (the reference's anti-flicker cache, by net
|
||||
// entity — the same rung the unit picker keeps).
|
||||
mut last_pick: Local<Option<Entity>>,
|
||||
// The picker's own state, one bundled param ([`GoPickSet`] — the fn sits at Bevy's
|
||||
// 16-param ceiling): the pick-set cache, its stream edges, and the sticky-hover memory.
|
||||
mut cache: GoPickSet,
|
||||
) {
|
||||
let added_parts = &cache.added;
|
||||
let removed_parts = &mut cache.removed;
|
||||
let pickable = &mut *cache.cache;
|
||||
let last_pick = &mut *cache.last_pick;
|
||||
// The pick set (bevy's `HashSet` — the caster's type, not the `std` one this module uses
|
||||
// elsewhere), rebuilt on GO-part stream edges ONLY — the `WaterIndex` shape. It used to be
|
||||
// collected from every streamed GO part on every cursor frame (ChildOf hop + store probe +
|
||||
// hash insert each); membership only actually changes when GO models spawn or despawn, and
|
||||
// a GO's type id never changes after create. Maintained FIRST, ahead of every early return:
|
||||
// an `Added` edge is only visible on the frame it happens (the run advances this system's
|
||||
// change tick even through a `return`), so a rebuild parked behind the cursor gates would
|
||||
// silently drop parts streamed in while the pointer sat on UI. A despawned entry between
|
||||
// edges self-filters at the caster (`parts.get` on a dead entity misses).
|
||||
//
|
||||
// A transport-family GO (TRANSPORT 11 / MAP_OBJECT 14 / MO_TRANSPORT 15) never joins the
|
||||
// set: in the reference it has no pick geometry at all — the GO model resolver `0x5f80e0`
|
||||
// returns 0 for these three types (they render via the WMO/spline path, not an M2) and their
|
||||
// strategy's interaction predicates are constant-false (w2c1 + go-render-gate + the +0x14
|
||||
// vtable dump). Concretely: a ship's hull must not swallow the pick — an NPC on deck stays
|
||||
// hoverable through the railing, and the hull shows no gear or tooltip.
|
||||
if removed_parts.read().next().is_some() || !added_parts.is_empty() {
|
||||
pickable.clear();
|
||||
pickable.extend(go_parts.iter().filter(|&e| {
|
||||
let root = child_of.get(e).map_or(e, |c| c.parent());
|
||||
!stores
|
||||
.get(root)
|
||||
.is_ok_and(|s| matches!(s.0.gameobject_type_id(), 11 | 14 | 15))
|
||||
}));
|
||||
}
|
||||
hovered.target = None;
|
||||
hovered.guid = None;
|
||||
hovered.distance = f32::MAX;
|
||||
@@ -387,22 +435,6 @@ pub(super) fn update_hovered_object(
|
||||
let Some(cursor) = window.cursor_position() else {
|
||||
return;
|
||||
};
|
||||
// The caster takes bevy's `HashSet` (not the `std` one this module uses elsewhere).
|
||||
// A transport-family GO (TRANSPORT 11 / MAP_OBJECT 14 / MO_TRANSPORT 15) never joins the pick
|
||||
// set: in the reference it has no pick geometry at all — the GO model resolver `0x5f80e0`
|
||||
// returns 0 for these three types (they render via the WMO/spline path, not an M2) and their
|
||||
// strategy's interaction predicates are constant-false (w2c1 + go-render-gate + the +0x14
|
||||
// vtable dump). Concretely: a ship's hull must not swallow the pick — an NPC on deck stays
|
||||
// hoverable through the railing, and the hull shows no gear or tooltip.
|
||||
let pickable: bevy::platform::collections::HashSet<Entity> = go_parts
|
||||
.iter()
|
||||
.filter(|&e| {
|
||||
let root = child_of.get(e).map_or(e, |c| c.parent());
|
||||
!stores
|
||||
.get(root)
|
||||
.is_ok_and(|s| matches!(s.0.gameobject_type_id(), 11 | 14 | 15))
|
||||
})
|
||||
.collect();
|
||||
if pickable.is_empty() {
|
||||
*last_pick = None;
|
||||
return;
|
||||
@@ -424,7 +456,7 @@ pub(super) fn update_hovered_object(
|
||||
|
||||
// Pass 1 — the exact resident geometry, pure nearest-wins (priority-independent).
|
||||
let mut best: Option<(f32, Entity)> =
|
||||
benilla_world::interact::cast_pick_ray(ray, &pickable, &parts, false)
|
||||
benilla_world::interact::cast_pick_ray(ray, pickable, &parts, false)
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|(e, h)| (h.distance, e));
|
||||
@@ -437,7 +469,7 @@ pub(super) fn update_hovered_object(
|
||||
return;
|
||||
}
|
||||
let mut best2: Option<(f32, u32, Entity)> = None;
|
||||
for (part, hit) in benilla_world::interact::cast_pick_ray_inflated(ray, &pickable, &parts) {
|
||||
for (part, hit) in benilla_world::interact::cast_pick_ray_inflated(ray, pickable, &parts) {
|
||||
let net = resolve_net(part);
|
||||
let prio = if *last_pick == Some(net) {
|
||||
u32::MAX // the sticky-hover cache outranks everything
|
||||
|
||||
@@ -805,6 +805,9 @@ mod quest_timer_tests;
|
||||
#[cfg(test)]
|
||||
mod durability_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod screenshot_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod questlog_tests;
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Print screen's UI half (`assets/ui/ScreenshotStatus.xml`) against the shipped XML — and above
|
||||
//! all **B261's third clause: the "Screen Captured" line must not be in the file it announces.**
|
||||
//!
|
||||
//! That contract is an ORDERING, so it is tested as one. Two paths could put text in a picture and
|
||||
//! both are pinned here: the ordinary press (nothing is shown until the engine reports back, which
|
||||
//! it can only do after the readback) and the double press inside the 1.5 s fade (the previous
|
||||
//! shot's line is still up, and `TakeScreenshot` takes it down *before* asking).
|
||||
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
fn load_xml(s: &UiScript, file: &str) {
|
||||
let text = std::fs::read_to_string(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("assets/ui")
|
||||
.join(file),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||||
let report = benilla_ui::loader::load(s, &doc, &|_| None);
|
||||
assert!(
|
||||
report.errors.is_empty(),
|
||||
"{file}: loader errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
}
|
||||
|
||||
fn harness() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for f in [
|
||||
"Fonts.xml",
|
||||
"UiPanels.xml",
|
||||
"UIParent.xml",
|
||||
"ScreenshotStatus.xml",
|
||||
] {
|
||||
load_xml(&s, f);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn shown(s: &UiScript) -> bool {
|
||||
s.eval::<bool>("return ScreenshotStatus:IsVisible()")
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn text(s: &UiScript) -> String {
|
||||
s.eval::<String>("return ScreenshotStatusText:GetText() or \"\"")
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn alpha(s: &UiScript) -> f64 {
|
||||
s.eval::<f64>("return ScreenshotStatus:GetAlpha()").unwrap()
|
||||
}
|
||||
|
||||
/// **The bug, as a test.** A press asks the engine and shows NOTHING; the line appears only when
|
||||
/// the engine reports back, which in the real client is frames later — so the frame that was
|
||||
/// captured cannot contain it.
|
||||
#[test]
|
||||
fn the_capture_is_asked_for_silently_and_only_the_answer_speaks() {
|
||||
let mut s = harness();
|
||||
assert!(!shown(&s), "the status line starts hidden");
|
||||
|
||||
s.run("TakeScreenshot()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_screenshot_asks(),
|
||||
1,
|
||||
"the binding body reaches the engine verb"
|
||||
);
|
||||
assert!(
|
||||
!shown(&s),
|
||||
"NOTHING is on screen at the moment of capture — this is B261's whole contract"
|
||||
);
|
||||
|
||||
// The engine's answer, one or more frames later.
|
||||
s.fire_event("SCREENSHOT_SUCCEEDED", Vec::new());
|
||||
assert!(shown(&s));
|
||||
assert_eq!(text(&s), "Screen Captured");
|
||||
assert_eq!(alpha(&s), 1.0);
|
||||
}
|
||||
|
||||
/// **The second leak path, and the reason `TakeScreenshot` hides rather than waiting for the
|
||||
/// fade.** Press again while the last confirmation is still on screen and it comes off BEFORE the
|
||||
/// engine is asked — otherwise that shot would have "Screen Captured" printed across it.
|
||||
#[test]
|
||||
fn a_second_press_inside_the_fade_clears_the_line_before_capturing() {
|
||||
let mut s = harness();
|
||||
s.fire_event("SCREENSHOT_SUCCEEDED", Vec::new());
|
||||
s.tick(0.5);
|
||||
assert!(shown(&s), "half a second in, the line is still up");
|
||||
assert!(alpha(&s) < 1.0, "and already fading");
|
||||
|
||||
s.run("TakeScreenshot()").unwrap();
|
||||
assert!(
|
||||
!shown(&s),
|
||||
"hidden BEFORE the engine is asked, not after it answers"
|
||||
);
|
||||
assert_eq!(s.take_screenshot_asks(), 1);
|
||||
|
||||
// The new answer restarts the fade at full rather than inheriting the old alpha.
|
||||
s.fire_event("SCREENSHOT_SUCCEEDED", Vec::new());
|
||||
assert_eq!(alpha(&s), 1.0);
|
||||
}
|
||||
|
||||
/// The 1.5 s fade: alpha falls with elapsed time and the frame takes itself off screen at the end.
|
||||
#[test]
|
||||
fn the_line_fades_out_over_the_reference_s_second_and_a_half() {
|
||||
let mut s = harness();
|
||||
s.fire_event("SCREENSHOT_SUCCEEDED", Vec::new());
|
||||
|
||||
s.tick(0.75);
|
||||
let half = alpha(&s);
|
||||
assert!(
|
||||
(half - 0.5).abs() < 1e-3,
|
||||
"halfway through 1.5 s the line is at half alpha, got {half}"
|
||||
);
|
||||
|
||||
s.tick(0.75);
|
||||
assert!(!shown(&s), "and it is gone at the end of the fade");
|
||||
}
|
||||
|
||||
/// The failure path says so, in the reference's own words.
|
||||
#[test]
|
||||
fn a_failed_capture_shows_the_failure_string() {
|
||||
let mut s = harness();
|
||||
s.fire_event("SCREENSHOT_FAILED", Vec::new());
|
||||
assert!(shown(&s));
|
||||
assert_eq!(text(&s), "Screen Capture Failed");
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! **The tree's one civil-time conversion** — broken-down UTC from an epoch second.
|
||||
//!
|
||||
//! It lives here because this crate already had to have one: 1.12's `date()` global is Lua 5.0's
|
||||
//! `os.date` hoisted, and the sandbox strips `os` ([`crate::script`]'s stdlib), so the calendar
|
||||
//! had to be written by hand. The screenshot writer is its second caller (decision 1487), and one
|
||||
//! correct closed form with two callers beats two hand-rolled loops.
|
||||
//!
|
||||
//! **UTC, deliberately, and it is a stated divergence wherever a caller wanted local time.** The
|
||||
//! workspace has no date dependency at all — every format crate in it is in-repo — and resolving a
|
||||
//! local offset needs a timezone source, not a different algorithm. `date()` records the same
|
||||
//! divergence for the same reason.
|
||||
|
||||
/// Broken-down UTC time.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct Civil {
|
||||
pub year: i64,
|
||||
/// 1–12.
|
||||
pub month: u32,
|
||||
/// 1–31.
|
||||
pub day: u32,
|
||||
pub hour: u32,
|
||||
pub min: u32,
|
||||
pub sec: u32,
|
||||
/// 0 = Sunday.
|
||||
pub weekday: u32,
|
||||
/// 1–366.
|
||||
pub yearday: u32,
|
||||
}
|
||||
|
||||
/// Break an epoch second down into UTC fields.
|
||||
///
|
||||
/// The civil-from-days conversion is Howard Hinnant's, shifted to a March-based year so the leap
|
||||
/// day lands at the end and no month-length table is needed. Chosen over a hand-rolled loop
|
||||
/// because it is a known-correct closed form with no accumulation error, which matters for the
|
||||
/// "persisted last session, compared this session" use every corpus `date()` caller has.
|
||||
pub fn from_unix(secs: i64) -> Civil {
|
||||
let days = secs.div_euclid(86_400);
|
||||
let rem = secs.rem_euclid(86_400);
|
||||
let (hour, min, sec) = (
|
||||
(rem / 3600) as u32,
|
||||
((rem % 3600) / 60) as u32,
|
||||
(rem % 60) as u32,
|
||||
);
|
||||
// 1970-01-01 was a Thursday (4).
|
||||
let weekday = (days + 4).rem_euclid(7) as u32;
|
||||
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
let year = if month <= 2 { y + 1 } else { y };
|
||||
|
||||
// Day of the year, from the same conversion applied to Jan 1.
|
||||
let yearday = (days - days_from_civil(year, 1, 1) + 1) as u32;
|
||||
Civil {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
min,
|
||||
sec,
|
||||
weekday,
|
||||
yearday,
|
||||
}
|
||||
}
|
||||
|
||||
/// Days since the epoch for a civil date — the inverse of the above, used only for `yearday`.
|
||||
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y.rem_euclid(400);
|
||||
let mp = if m > 2 { m - 3 } else { m + 9 } as i64;
|
||||
let doy = (153 * mp + 2) / 5 + d as i64 - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
/// Wall-clock seconds since the Unix epoch. Before the epoch is not a state a game client is in; a
|
||||
/// clock that somehow reports it clamps to 0 rather than raising inside an addon's file scope.
|
||||
pub fn unix_seconds() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Four instants that between them cover the epoch itself, a leap day, a century boundary and
|
||||
/// an ordinary afternoon — the cases a closed-form conversion gets wrong when it is wrong.
|
||||
#[test]
|
||||
fn known_instants_break_down() {
|
||||
let cases = [
|
||||
(0_i64, 1970, 1, 1, 0, 0, 0, 4, 1),
|
||||
(1_787_319_907, 2026, 8, 21, 13, 45, 7, 5, 233),
|
||||
(1_709_251_199, 2024, 2, 29, 23, 59, 59, 4, 60),
|
||||
(946_598_400, 1999, 12, 31, 0, 0, 0, 5, 365),
|
||||
];
|
||||
for (secs, year, month, day, hour, min, sec, weekday, yearday) in cases {
|
||||
assert_eq!(
|
||||
from_unix(secs),
|
||||
Civil {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
min,
|
||||
sec,
|
||||
weekday,
|
||||
yearday
|
||||
},
|
||||
"epoch second {secs}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Before 1970 is not a state a client is in, but the arithmetic must not wrap into nonsense
|
||||
/// if a clock reports it — `div_euclid`/`rem_euclid` are load-bearing here, not stylistic.
|
||||
#[test]
|
||||
fn negative_seconds_stay_civil() {
|
||||
assert_eq!(
|
||||
from_unix(-1),
|
||||
Civil {
|
||||
year: 1969,
|
||||
month: 12,
|
||||
day: 31,
|
||||
hour: 23,
|
||||
min: 59,
|
||||
sec: 59,
|
||||
weekday: 3,
|
||||
yearday: 365
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
//! to text only where a Rust parser forces it (decision 1193).
|
||||
|
||||
pub mod bindings_xml;
|
||||
pub mod civil;
|
||||
pub mod framexml;
|
||||
pub mod justify;
|
||||
pub mod layout;
|
||||
|
||||
@@ -71,6 +71,7 @@ mod font_block;
|
||||
mod gossip;
|
||||
mod guild;
|
||||
mod handler_prof;
|
||||
mod screenshot;
|
||||
pub use handler_prof::HandlerRow;
|
||||
mod inspect;
|
||||
mod item_stats;
|
||||
@@ -503,6 +504,7 @@ impl UiScript {
|
||||
channel::install(&lua)?;
|
||||
chat_window::install(&lua)?;
|
||||
client::install(&lua)?;
|
||||
screenshot::install(&lua)?;
|
||||
stdlib::sandbox(&lua)?;
|
||||
// Before the stdlib layer, so its aliases bind the 5.0-shaped functions (decision 1194).
|
||||
lua50::install(&lua)?;
|
||||
|
||||
@@ -1136,6 +1136,10 @@ pub(crate) struct Model {
|
||||
/// `CMSG_PLAYED_TIME`. A COUNT, not a payload, for [`super::pvp`]'s reason: the packet is
|
||||
/// empty, so two asks in a frame are two sends rather than one collapsed intent.
|
||||
pub(crate) played_time_asks: u32,
|
||||
/// `Screenshot()` calls queued since the app last drained them — each is one capture
|
||||
/// (decision 1487). A COUNT for [`Self::played_time_asks`]'s reason: the request carries no
|
||||
/// payload, so two calls in a frame are two captures.
|
||||
pub(crate) screenshot_asks: u32,
|
||||
pub(crate) realm_name: String,
|
||||
/// The hearthstone bind location's NAME, behind `GetBindLocation()` — the app resolves the
|
||||
/// `SMSG_BINDPOINTUPDATE` area id through the same AreaTable catalog the hearthstone's `$z`
|
||||
@@ -1439,6 +1443,7 @@ impl Model {
|
||||
chat_sends: Vec::new(),
|
||||
addon_sends: Vec::new(),
|
||||
played_time_asks: 0,
|
||||
screenshot_asks: 0,
|
||||
realm_name: String::new(),
|
||||
bind_location: String::new(),
|
||||
binder_confirms: 0,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//! **`Screenshot()`** — the engine verb behind the print-screen key (decision 1487).
|
||||
//!
|
||||
//! One global and one counter. The reference's binding is `SCREENSHOT` → `TakeScreenshot()`
|
||||
//! (FrameXML `Bindings.xml`), whose body hides the status text and then calls this engine
|
||||
//! function; the engine answers later with `SCREENSHOT_SUCCEEDED` or `SCREENSHOT_FAILED`, which
|
||||
//! is what puts "Screen Captured" on screen. **The answer is an EVENT, never a return value** —
|
||||
//! and that is the whole mechanism that keeps the message out of the file it announces: the
|
||||
//! capture is already taken by the time anything can say so.
|
||||
//!
|
||||
//! A **count**, like [`super::client`]'s `RequestTimePlayed` and `ConfirmBinder`: the request
|
||||
//! carries no payload (the app owns the window, the folder and the clock), so two calls in a frame
|
||||
//! are two captures rather than one collapsed intent. The app drains it in
|
||||
//! [`crate::script::UiScript::take_screenshot_asks`].
|
||||
|
||||
use mlua::Lua;
|
||||
|
||||
use super::Model;
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// `Screenshot()` — take one. Returns nothing: the reference's does not either, because the
|
||||
// capture completes after the frame that asked for it and the outcome arrives as
|
||||
// SCREENSHOT_SUCCEEDED / SCREENSHOT_FAILED.
|
||||
lua.globals().set(
|
||||
"Screenshot",
|
||||
lua.create_function(|lua, ()| {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.screenshot_asks += 1;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Drain the `Screenshot()` calls queued since the last call — each one is one capture.
|
||||
/// [`super::UiScript::take_played_time_asks`]'s shape, for the same reason.
|
||||
pub fn take_screenshot_asks(&mut self) -> u32 {
|
||||
std::mem::take(&mut self.model_mut().screenshot_asks)
|
||||
}
|
||||
}
|
||||
@@ -443,13 +443,16 @@ end
|
||||
/// they shift by a constant. Fixing it means a tz source, not a different algorithm.
|
||||
fn install_time(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
g.set("time", lua.create_function(|_, ()| Ok(unix_seconds()))?)?;
|
||||
g.set(
|
||||
"time",
|
||||
lua.create_function(|_, ()| Ok(crate::civil::unix_seconds()))?,
|
||||
)?;
|
||||
g.set(
|
||||
"date",
|
||||
lua.create_function(|lua, (fmt, when): (Option<String>, Option<i64>)| {
|
||||
// Bare `date()` is `%c`, as in Lua — `Recap.lua:2690` calls it with no arguments.
|
||||
let fmt = fmt.unwrap_or_else(|| "%c".to_string());
|
||||
let secs = when.unwrap_or_else(unix_seconds);
|
||||
let secs = when.unwrap_or_else(crate::civil::unix_seconds);
|
||||
// A leading `!` selects UTC. Every timestamp this engine holds is already UTC — there
|
||||
// is no local-time conversion anywhere here — so the flag only has to be CONSUMED, not
|
||||
// acted on. Left in the format string it would print as a literal `!`.
|
||||
@@ -465,18 +468,18 @@ fn install_time(lua: &Lua) -> mlua::Result<()> {
|
||||
// the client — which is exactly what it did to the survey the moment a separate fix let
|
||||
// Accountant reach this function at all.
|
||||
if body == "*t" {
|
||||
let (year, month, day, hour, min, sec, wday, yday) = civil_from_epoch(secs);
|
||||
let c = crate::civil::from_unix(secs);
|
||||
let t = lua.create_table()?;
|
||||
t.set("year", year)?;
|
||||
t.set("month", month)?;
|
||||
t.set("day", day)?;
|
||||
t.set("hour", hour)?;
|
||||
t.set("min", min)?;
|
||||
t.set("sec", sec)?;
|
||||
t.set("year", c.year)?;
|
||||
t.set("month", c.month)?;
|
||||
t.set("day", c.day)?;
|
||||
t.set("hour", c.hour)?;
|
||||
t.set("min", c.min)?;
|
||||
t.set("sec", c.sec)?;
|
||||
// Lua counts weekdays 1..7 from SUNDAY; ours is the 0-based index into `DAYS`.
|
||||
t.set("wday", wday + 1)?;
|
||||
t.set("wday", c.weekday + 1)?;
|
||||
// `yday` is already 1-based out of the conversion, which is Lua's convention too.
|
||||
t.set("yday", yday)?;
|
||||
t.set("yday", c.yearday)?;
|
||||
// No timezone and no DST rules here, and `false` is the honest answer rather than
|
||||
// the nil an absent field would give: this clock never observes daylight saving.
|
||||
t.set("isdst", false)?;
|
||||
@@ -490,15 +493,6 @@ fn install_time(lua: &Lua) -> mlua::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wall-clock seconds since the Unix epoch. Before the epoch is not a state a game client is in;
|
||||
/// a clock that somehow reports it clamps to 0 rather than raising inside an addon's file scope.
|
||||
fn unix_seconds() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
const DAYS: [&str; 7] = [
|
||||
"Sunday",
|
||||
"Monday",
|
||||
@@ -523,52 +517,6 @@ const MONTHS: [&str; 12] = [
|
||||
"December",
|
||||
];
|
||||
|
||||
/// Broken-down UTC time from an epoch second: (year, month 1-12, day 1-31, hour, min, sec,
|
||||
/// weekday 0=Sunday, yearday 1-366).
|
||||
///
|
||||
/// The civil-from-days conversion is Howard Hinnant's, shifted to a March-based year so the leap
|
||||
/// day lands at the end and no month-length table is needed. Chosen over a hand-rolled loop because
|
||||
/// it is a known-correct closed form with no accumulation error, which matters for the "persisted
|
||||
/// last session, compared this session" use every corpus caller has.
|
||||
fn civil_from_epoch(secs: i64) -> (i64, u32, u32, u32, u32, u32, u32, u32) {
|
||||
let days = secs.div_euclid(86_400);
|
||||
let rem = secs.rem_euclid(86_400);
|
||||
let (hour, min, sec) = (
|
||||
(rem / 3600) as u32,
|
||||
((rem % 3600) / 60) as u32,
|
||||
(rem % 60) as u32,
|
||||
);
|
||||
// 1970-01-01 was a Thursday (4).
|
||||
let weekday = (days + 4).rem_euclid(7) as u32;
|
||||
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
let year = if month <= 2 { y + 1 } else { y };
|
||||
|
||||
// Day of the year, from the same conversion applied to Jan 1.
|
||||
let jan1 = days_from_civil(year, 1, 1);
|
||||
let yearday = (days - jan1 + 1) as u32;
|
||||
(year, month, day, hour, min, sec, weekday, yearday)
|
||||
}
|
||||
|
||||
/// Days since the epoch for a civil date — the inverse of the above, used only for `%j`.
|
||||
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y.rem_euclid(400);
|
||||
let mp = if m > 2 { m - 3 } else { m + 9 } as i64;
|
||||
let doy = (153 * mp + 2) / 5 + d as i64 - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
/// The `strftime` subset the corpus actually uses, plus the obvious neighbours.
|
||||
///
|
||||
/// Bounded on purpose: the specifiers here are the ones read off real call sites —
|
||||
@@ -576,7 +524,16 @@ fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
|
||||
/// `%c` (bare `date()`). An unknown specifier is emitted VERBATIM rather than swallowed, so a
|
||||
/// format this does not know shows up in the output instead of silently vanishing.
|
||||
fn format_epoch(secs: i64, fmt: &str) -> String {
|
||||
let (year, month, day, hour, min, sec, wday, yday) = civil_from_epoch(secs);
|
||||
let crate::civil::Civil {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
min,
|
||||
sec,
|
||||
weekday: wday,
|
||||
yearday: yday,
|
||||
} = crate::civil::from_unix(secs);
|
||||
let mut out = String::with_capacity(fmt.len() + 16);
|
||||
let mut chars = fmt.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
|
||||
@@ -137,6 +137,12 @@ pub struct GroundShade {
|
||||
/// The last effective target the `WOW_INTERIOR_LOG` instrument printed (log-on-change; starts
|
||||
/// off-scale so the first resolved target always prints).
|
||||
logged_target: f32,
|
||||
/// The shade byte the descendant walk last pushed to this root's parts — the settled gate
|
||||
/// (1490): while it matches this frame's byte and no other lane rewrote a part's tag (the
|
||||
/// reclaim detector in [`update_ground_shade`]), the walk is skipped whole. `None` = the
|
||||
/// parts are not known to carry any byte — fresh, or hidden by the election (a wake must
|
||||
/// re-assert even at an unchanged byte).
|
||||
asserted: Option<u8>,
|
||||
}
|
||||
|
||||
impl Default for GroundShade {
|
||||
@@ -151,6 +157,7 @@ impl Default for GroundShade {
|
||||
ambient: Vec3::ZERO,
|
||||
ambient_target: Vec3::ZERO,
|
||||
logged_target: -1.0,
|
||||
asserted: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,13 +204,55 @@ impl GroundShade {
|
||||
}
|
||||
}
|
||||
|
||||
/// Roots owed a shade re-assert this frame even at an unchanged byte: some OTHER lane rewrote a
|
||||
/// descendant part's `MeshTag` since [`detect_shade_reclaims`] last ran — the interior
|
||||
/// classifier's exterior reclaim (1358), a late-attached part (`Added` counts as changed).
|
||||
/// What lets [`update_ground_shade`]'s per-part walk skip the settled crowd (1490): steady
|
||||
/// frames have no foreign tag writes, so this is empty and every settled drawn body costs the
|
||||
/// ramp arithmetic and one map insert.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct ShadeDirtyRoots(bevy::ecs::entity::EntityHashSet);
|
||||
|
||||
/// The reclaim detector — its own system because `Changed<MeshTag>` is read access and the
|
||||
/// walk's query holds `&mut MeshTag` (B0001). Runs between the classifier and the walk, so a
|
||||
/// same-frame reclaim is caught the frame it happens; a writer scheduled after the walk
|
||||
/// (a PostUpdate fade) is caught on the next frame, exactly when the old unconditional walk
|
||||
/// would have repaired it too. The walk's own writes land after this system's baseline and so
|
||||
/// echo back as dirty for ONE extra frame — that echo walk compares every byte equal, writes
|
||||
/// nothing, and the chain goes quiet; it cannot oscillate.
|
||||
pub(crate) fn detect_shade_reclaims(
|
||||
changed_parts: Query<Entity, (Changed<MeshTag>, Without<crate::billboard::BillboardCard>)>,
|
||||
shade_roots: Query<(), With<GroundShade>>,
|
||||
child_of: Query<&ChildOf>,
|
||||
mut dirty: ResMut<ShadeDirtyRoots>,
|
||||
) {
|
||||
dirty.0.clear();
|
||||
for part in &changed_parts {
|
||||
let mut node = part;
|
||||
loop {
|
||||
if shade_roots.contains(node) {
|
||||
dirty.0.insert(node);
|
||||
break;
|
||||
}
|
||||
let Ok(up) = child_of.get(node) else { break };
|
||||
node = up.parent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct EntityShadePlugin;
|
||||
|
||||
impl Plugin for EntityShadePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// After the interior classifier: its exterior reclaim writes a fresh tag (shade byte 0) the
|
||||
// same frame this re-asserts the ramped byte, so the pair can't fight across frames.
|
||||
app.add_systems(Update, update_ground_shade.after(classify_entity_interior));
|
||||
// same frame this re-asserts the ramped byte, so the pair can't fight across frames. The
|
||||
// detector sits between the two so the reclaim is observed the frame it happens.
|
||||
app.init_resource::<ShadeDirtyRoots>().add_systems(
|
||||
Update,
|
||||
(detect_shade_reclaims, update_ground_shade)
|
||||
.chain()
|
||||
.after(classify_entity_interior),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +292,9 @@ pub(crate) fn update_ground_shade(
|
||||
// The card pass's up-walk: a card can follow a deep JOINT (`following_joint` — the swinging
|
||||
// lamp's glow), and a joint is not a shade root.
|
||||
child_of: Query<&ChildOf>,
|
||||
// The reclaim detector's verdict ([`detect_shade_reclaims`], one stage earlier): roots owed
|
||||
// a re-assert even at an unchanged byte.
|
||||
dirty_roots: Res<ShadeDirtyRoots>,
|
||||
mut self_log: Local<f32>,
|
||||
) {
|
||||
root_shade.clear();
|
||||
@@ -328,20 +380,31 @@ pub(crate) fn update_ground_shade(
|
||||
ramp_toward(a.z, at.z, ambient_step),
|
||||
);
|
||||
let byte = (shade.t * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
// Every root enters the card map, hidden or not — the insert is one hash write, and a
|
||||
// hidden body's card used to walk its whole parent chain to a `None` precisely because
|
||||
// its root was left out.
|
||||
root_shade.insert(root, byte);
|
||||
// A root the election hid (1270/1475) draws nothing, so the re-assert walk below is
|
||||
// skipped whole — 1473's audit found it running over every part of every off-view body.
|
||||
// The ramps above kept stepping, so the byte is current the frame the body wakes; this
|
||||
// system runs one stage before the election (Update vs PostUpdate), so the wake frame's
|
||||
// walk re-asserts at most one frame late, into parts whose tags retained their last byte.
|
||||
// The ramps above kept stepping, so the byte is current the frame the body wakes; the
|
||||
// cleared `asserted` is what makes the wake frame's walk unconditional, into parts
|
||||
// whose tags retained their last byte.
|
||||
if root_vis.is_some_and(|v| *v == Visibility::Hidden) {
|
||||
shade.asserted = None;
|
||||
continue;
|
||||
}
|
||||
// The settled gate (1490): every part below already carries this byte (we pushed it —
|
||||
// `asserted`) and no other lane rewrote a tag since (`dirty_roots`). The walk used to be
|
||||
// unconditional for a drawn root as the MeshTag re-assert over the classifier's
|
||||
// exterior reclaim (1358); the reclaim detector above answers that by observation
|
||||
// instead of by re-walking every body every frame.
|
||||
if shade.asserted == Some(byte) && !dirty_roots.0.contains(&root) {
|
||||
continue;
|
||||
}
|
||||
shade.asserted = Some(byte);
|
||||
// Push to every part below the root (body submeshes are direct children; held items/helm ride
|
||||
// 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. The
|
||||
// walk itself is unconditional for a DRAWN root: it is the MeshTag re-assert over the
|
||||
// classifier's exterior reclaim (1358), not a change-only push.
|
||||
root_shade.insert(root, byte);
|
||||
// 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 {
|
||||
continue;
|
||||
@@ -417,6 +480,39 @@ mod tests {
|
||||
card_root_shade(map, &state.get(world), follows)
|
||||
}
|
||||
|
||||
/// The reclaim detector's tick contract (1490): a fresh part flags its root (Added counts
|
||||
/// as changed); a quiet frame flags nothing; a foreign tag write flags the root again — and
|
||||
/// only the root above the touched part, not every root in the world. This is what lets
|
||||
/// the shade walk skip the settled crowd without ever missing 1358's exterior reclaim.
|
||||
#[test]
|
||||
fn a_foreign_tag_write_flags_exactly_its_root() {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<ShadeDirtyRoots>()
|
||||
.add_systems(Update, detect_shade_reclaims);
|
||||
let root = app.world_mut().spawn(GroundShade::default()).id();
|
||||
let part = app.world_mut().spawn((MeshTag(7), ChildOf(root))).id();
|
||||
let other = app.world_mut().spawn(GroundShade::default()).id();
|
||||
app.world_mut().spawn((MeshTag(9), ChildOf(other)));
|
||||
|
||||
app.update(); // both parts are Added ⇒ both roots flagged
|
||||
let dirty = |app: &App| {
|
||||
let d = app.world().resource::<ShadeDirtyRoots>();
|
||||
(d.0.contains(&root), d.0.contains(&other))
|
||||
};
|
||||
assert_eq!(dirty(&app), (true, true), "a fresh part flags its root");
|
||||
|
||||
app.update(); // nobody wrote ⇒ quiet
|
||||
assert_eq!(dirty(&app), (false, false), "a settled frame flags nothing");
|
||||
|
||||
app.world_mut().get_mut::<MeshTag>(part).unwrap().0 = 42;
|
||||
app.update();
|
||||
assert_eq!(
|
||||
dirty(&app),
|
||||
(true, false),
|
||||
"a foreign write flags its own root and nobody else's"
|
||||
);
|
||||
}
|
||||
|
||||
/// A card's owner can sit arbitrarily deep in its root's tree (a held torch's flame card
|
||||
/// follows a hand JOINT under an item entity under the unit) — the up-walk finds the root's
|
||||
/// byte; the root itself, as an owner, is the walk's zero-hop case.
|
||||
|
||||
@@ -92,7 +92,7 @@ pub use query::{
|
||||
FoamPatch, LiquidClaim, LiquidHit, LiquidSource, RoomPlacements, Underwater, WaterChunkInfo,
|
||||
WmoPool,
|
||||
};
|
||||
pub(crate) use spatial::WaterIndex;
|
||||
pub(crate) use spatial::{maintain_water_index, WaterIndex};
|
||||
pub(crate) use surface::{spawn_liquids, spawn_wmo_liquids, LiquidAssets, LiquidSoundSource};
|
||||
|
||||
/// The frame slot where [`Underwater`] is written — the label every consumer of the submersion
|
||||
@@ -117,7 +117,7 @@ impl Plugin for LiquidPlugin {
|
||||
// PreUpdate: surfaces stream in/out via Update-side commands, so the edge is visible
|
||||
// here the frame after — before any of that frame's consumers ask. A despawn's stale
|
||||
// entry in between self-filters at the consumer (`Query::get` misses).
|
||||
.add_systems(PreUpdate, spatial::maintain_water_index)
|
||||
.add_systems(PreUpdate, maintain_water_index)
|
||||
.add_systems(Startup, surface::setup_liquid.after(AssetSet::Open))
|
||||
.add_systems(
|
||||
Update,
|
||||
|
||||
@@ -49,12 +49,33 @@ impl WaterIndex {
|
||||
.get(&Self::cell_of(x, y))
|
||||
.map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Every surface registered in any cell the WoW-space box `[lo, hi]` touches, deduplicated —
|
||||
/// the candidate set for a small box query (the foam patch builder), with [`Self::over`]'s
|
||||
/// same superset guarantee: a surface overlapping the box overlaps one of its cells, and a
|
||||
/// surface is registered in every cell its footprint overlaps. Boxes here are a few yards
|
||||
/// (1–4 cells), buckets are short — the linear dedup is the cheap shape.
|
||||
pub(crate) fn over_box(&self, lo: [f32; 2], hi: [f32; 2]) -> Vec<Entity> {
|
||||
let [x0, y0] = Self::cell_of(lo[0], lo[1]);
|
||||
let [x1, y1] = Self::cell_of(hi[0], hi[1]);
|
||||
let mut out = Vec::new();
|
||||
for cx in x0..=x1 {
|
||||
for cy in y0..=y1 {
|
||||
for &e in self.cells.get(&[cx, cy]).map_or(&[][..], Vec::as_slice) {
|
||||
if !out.contains(&e) {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild [`WaterIndex`] when the surface population changed — tile stream edges only. A full
|
||||
/// rebuild over ~2k surfaces is microseconds, so incremental bookkeeping would be complexity
|
||||
/// with nothing to buy.
|
||||
pub(super) fn maintain_water_index(
|
||||
pub(crate) fn maintain_water_index(
|
||||
mut index: ResMut<WaterIndex>,
|
||||
added: Query<(), Added<WaterChunkInfo>>,
|
||||
mut removed: RemovedComponents<WaterChunkInfo>,
|
||||
|
||||
@@ -49,7 +49,7 @@ use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
|
||||
|
||||
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
|
||||
|
||||
use crate::liquid::{FoamPatch, WaterChunkInfo};
|
||||
use crate::liquid::{FoamPatch, WaterChunkInfo, WaterIndex};
|
||||
use crate::particles::buffer::{
|
||||
begin_effect_frame, EffectBlend, EffectDrawSpec, EffectFog, EffectQuads, EffectVertex,
|
||||
};
|
||||
@@ -308,7 +308,8 @@ fn drive_unit(
|
||||
scale: f32,
|
||||
// The unit's collision height (yd) — both depth lines below are fractions of it (0645).
|
||||
h: f32,
|
||||
chunks: &[(Entity, &WaterChunkInfo, &FoamPatch)],
|
||||
water: &Query<(Entity, &WaterChunkInfo, &FoamPatch)>,
|
||||
index: &WaterIndex,
|
||||
now: f32,
|
||||
) {
|
||||
let gate = (GATE_DEPTH_FRAC * h).max(1.0);
|
||||
@@ -316,11 +317,15 @@ fn drive_unit(
|
||||
let wow = bevy_to_wow(pos);
|
||||
// The surface height under the unit, from the wet cell it stands on — not the chunk's box and
|
||||
// not the chunk's highest vertex, which on a sloped river put the wade depth ~2 yd out
|
||||
// (decision 0642).
|
||||
let Some(surface) = chunks
|
||||
.iter()
|
||||
.find_map(|(_, info, _)| info.surface_z_at(wow[0], wow[1]))
|
||||
else {
|
||||
// (decision 0642). Through [`WaterIndex`], not the full chunk walk: this line used to be a
|
||||
// linear scan of every loaded surface (~2.2k at a city pin) PER UNIT PER FRAME — exactly the
|
||||
// per-consumer shape `liquid/spatial.rs`'s module doc warns detonates; a dry-land unit now
|
||||
// costs one hash miss. A dead index entry self-filters at `water.get` (the index contract),
|
||||
// and overlapping surfaces were already first-match-in-arbitrary-order before this.
|
||||
let Some(surface) = index.over(wow[0], wow[1]).iter().find_map(|&e| {
|
||||
let (_, info, _) = water.get(e).ok()?;
|
||||
info.surface_z_at(wow[0], wow[1])
|
||||
}) else {
|
||||
foam_state.wading = false;
|
||||
return;
|
||||
};
|
||||
@@ -345,7 +350,19 @@ fn drive_unit(
|
||||
};
|
||||
let final_size = p.size0 + p.growth * p.lifetime;
|
||||
let center = [wow[0], wow[1]];
|
||||
if let Some((verts, chunk)) = build_patch(center, final_size, chunks) {
|
||||
// Candidates for the patch box only — resolved here, on the EMISSION path (past the depth
|
||||
// gate and the cooldown), never on the every-frame classify above. `over_box` unions the
|
||||
// 1–4 cells a final-size box touches, so the set `build_patch` filters with `overlaps` is
|
||||
// the same one the full walk offered it.
|
||||
let candidates: Vec<_> = index
|
||||
.over_box(
|
||||
[center[0] - final_size, center[1] - final_size],
|
||||
[center[0] + final_size, center[1] + final_size],
|
||||
)
|
||||
.into_iter()
|
||||
.filter_map(|e| water.get(e).ok())
|
||||
.collect();
|
||||
if let Some((verts, chunk)) = build_patch(center, final_size, &candidates) {
|
||||
alloc(FoamRecord {
|
||||
center,
|
||||
heading,
|
||||
@@ -377,6 +394,7 @@ fn drive_unit(
|
||||
|
||||
/// Per frame: run the driver for the avatar (its real movement flags — the reference's own
|
||||
/// selection bits) and for every streamed unit (the velocity/yaw proxy), emitting pool records.
|
||||
#[allow(clippy::too_many_arguments)] // the emitter's real input set; the index ride-along tipped it
|
||||
fn emit_water_foam(
|
||||
time: Res<Time>,
|
||||
materials: Option<Res<FoamAssets>>,
|
||||
@@ -385,19 +403,19 @@ fn emit_water_foam(
|
||||
self_unit: Query<&WorldUnit, With<ViewerUnit>>,
|
||||
units: Query<(Entity, &Transform, &WorldUnit), Without<ViewerUnit>>,
|
||||
water: Query<(Entity, &WaterChunkInfo, &FoamPatch)>,
|
||||
index: Res<WaterIndex>,
|
||||
) {
|
||||
if materials.is_none() {
|
||||
return;
|
||||
}
|
||||
let now = time.elapsed_secs();
|
||||
let dt = time.delta_secs().max(1.0e-4);
|
||||
let chunks: Vec<_> = water.iter().collect();
|
||||
|
||||
for uf in foam.units.values_mut() {
|
||||
uf.active = false;
|
||||
}
|
||||
|
||||
if !chunks.is_empty() {
|
||||
if !water.is_empty() {
|
||||
// The avatar: byte-faithful selection off our own streamed movement flags.
|
||||
if let Some(body) = viewer.at {
|
||||
let uf = foam
|
||||
@@ -432,7 +450,7 @@ fn emit_water_foam(
|
||||
let mut alloc = |rec: FoamRecord| {
|
||||
pool[alloc_slot(self_cursor, 0, SELF_SLOTS)] = Some(rec);
|
||||
};
|
||||
drive_unit(uf, &mut alloc, body, state, scale, h, &chunks, now);
|
||||
drive_unit(uf, &mut alloc, body, state, scale, h, &water, &index, now);
|
||||
}
|
||||
|
||||
// Streamed units (the avatar's own wire ghost is excluded via `Without<SelfPlayer>`).
|
||||
@@ -483,7 +501,9 @@ fn emit_water_foam(
|
||||
let mut alloc = |rec: FoamRecord| {
|
||||
pool[alloc_slot(other_cursor, SELF_SLOTS, POOL_SIZE - SELF_SLOTS)] = Some(rec);
|
||||
};
|
||||
drive_unit(uf, &mut alloc, pos, state, unit.scale, h, &chunks, now);
|
||||
drive_unit(
|
||||
uf, &mut alloc, pos, state, unit.scale, h, &water, &index, now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,6 +624,75 @@ impl Plugin for WaterFxPlugin {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The emitter reaches its water through [`WaterIndex`], end to end: a wading unit standing
|
||||
/// in an indexed pool emits a record on its first frame (the step-in one-shot), and a unit
|
||||
/// on dry land emits nothing. This is the regression fence for the index rewire — an empty
|
||||
/// or stale index is a world with NO foam, which no helper-level test would catch.
|
||||
#[test]
|
||||
fn the_emitter_finds_its_water_through_the_index() {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<Time>()
|
||||
.init_resource::<WaterIndex>()
|
||||
.init_resource::<WaterFoam>()
|
||||
.init_resource::<crate::view::Viewer>()
|
||||
.insert_resource(FoamAssets {
|
||||
ring: Handle::default(),
|
||||
wake: Handle::default(),
|
||||
})
|
||||
.add_systems(
|
||||
Update,
|
||||
(crate::liquid::maintain_water_index, emit_water_foam).chain(),
|
||||
);
|
||||
|
||||
// A 3×3-vertex all-wet grid over [0,10]², surface z = 5.
|
||||
let mut positions = Vec::new();
|
||||
for j in 0..3 {
|
||||
for i in 0..3 {
|
||||
positions.push([i as f32 * 5.0, j as f32 * 5.0, 5.0]);
|
||||
}
|
||||
}
|
||||
let info = WaterChunkInfo::new(
|
||||
crate::liquid::LiquidSource::AdtChunk,
|
||||
benilla_formats::LiquidKind::Still,
|
||||
[3, 3],
|
||||
positions,
|
||||
vec![true; 4],
|
||||
);
|
||||
app.world_mut().spawn((info, FoamPatch));
|
||||
|
||||
let unit = || WorldUnit {
|
||||
wades: true,
|
||||
scale: 1.0,
|
||||
height: 2.0,
|
||||
bound: None,
|
||||
};
|
||||
// Feet 1.5 yd under the surface — past the 0.4·h step-in line, so the one-shot ring
|
||||
// fires on the first classified frame.
|
||||
app.world_mut().spawn((
|
||||
Transform::from_translation(wow_to_bevy([2.0, 2.0, 3.5])),
|
||||
unit(),
|
||||
));
|
||||
// The dry control, far outside every indexed cell.
|
||||
app.world_mut().spawn((
|
||||
Transform::from_translation(wow_to_bevy([500.0, 500.0, 3.5])),
|
||||
unit(),
|
||||
));
|
||||
|
||||
app.update();
|
||||
let foam = app.world().resource::<WaterFoam>();
|
||||
let live: Vec<usize> = foam
|
||||
.pool
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, r)| r.as_ref().map(|_| i))
|
||||
.collect();
|
||||
assert_eq!(live.len(), 1, "one record: the wader's, not the dry unit's");
|
||||
assert!(
|
||||
live[0] >= SELF_SLOTS,
|
||||
"a streamed unit allocates from the other partition"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pool partitioning: the self cursor wraps within [0, 32), others within [32, 128) —
|
||||
/// eviction replaces the oldest of the same partition, never crosses it.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user