trilinear ships on, and every line of text rounds once
- the texture filter is the reference's policy — two globals, one process-wide mode, no per-texture override — registered as real CVars every sampler reads: the hardcoded trilinear + aniso 8 at five sampler sites had been read off an apitrace of an install whose `Config.wtf` set `anisotropic "16"`, a configured value taken for a default; and `trilinear` ships ON, because the registrar's `"0"` is not what a fresh install renders at — first-launch hardware detection overwrites sixteen video CVars from its hardware table, both rows a real GPU can reach set trilinear, and the reference's own `gx.log` on this machine names that row — while `anisotropic` is not among the sixteen, so anisotropy stays off - the MSAA device clamp ran before the config was read, so a saved `gxMultisample = 8` carried from a machine that offers it still killed the render thread on one that stops at 4; the ceiling is now a property of the write itself, covering the config file, `SetCVar`, the dropdown and any writer added later — and an empty device list is no opinion, so headless runs pass through - one line, one rounding: at a fractional display scale the per-glyph snap's f32 round-trip moved half-pixel ties by an ulp, so caps and ascenders rounded up while x-height letters stayed — the letters left the baseline, the thing the reporter photographed; the line's two anchors now round once and each glyph adds integer device-pixel offsets, so a line cannot be anything but flat at any scale, proven over 15,680 layouts — and `$WOW_DPI`'s size guard, which compared a physical request against the logical size and refused every non-1x run, now reads the unit it is denominated in - a test that panics while holding `ENV_LOCK` no longer poisons the tests behind it — one missing knob resource had reported as twelve red tests, eleven naming the mutex and none naming the bug; there is nothing for the poison to protect, since the guard's `Drop` restores the environment during the panicking test's own unwind Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -289,6 +289,32 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// enumeration.
|
||||
("gxColorBits", "32"),
|
||||
("gxDepthBits", "32"),
|
||||
// **The texture filter policy** — 1.12's own `trilinear` and `anisotropic`, over
|
||||
// `benilla_assets::TexFilterSetting`. The defaults are the reference's registered strings, and
|
||||
// benilla had neither CVar: it hardcoded trilinear + aniso 8 at every sampler it built, which
|
||||
// is mode 5 — the *top* of what these two can ask for — shipped as the thing you get before
|
||||
// asking. `tex_filter.rs` carries the derivation and the cost.
|
||||
//
|
||||
// Latched, exactly like `gxMultisample` above and for a harder reason: a sampler is baked into
|
||||
// the `Image` at load and lives in the uploaded texture, so a live change would mean rebuilding
|
||||
// every texture in the world. The reference's own UI says "enabled upon restart".
|
||||
// `$WOW_TRILINEAR` / `$WOW_ANISO` override session-only, below.
|
||||
// **`trilinear` registers "1", not the registrar's "0"** (decision 1645, correcting 1642).
|
||||
// The reference's `CVar::Register` string is `"0"`, but `hwDetect` — registered `"1"` — runs
|
||||
// `DetectHardware 0x641260` at boot and `CVar::Set`s sixteen video CVars from the matched
|
||||
// `VideoHardware.dbc` row before the first frame, then self-clears. Every GPU this client runs
|
||||
// on is unlisted in a 2004 table, so the row is the fallback scan's, whose reachable set is
|
||||
// exactly rows 168/169/170 — and `trilinear` is 1 on 169 and 170, at both CPU tiers, with no
|
||||
// CPU bias term. Measured as well as derived: the reference's own `WoW/Logs/gx.log` reads
|
||||
// `VID: 106b` → `DID: 2` → `videoID: 170`.
|
||||
//
|
||||
// This is the same shape as `gxMultisample` above, which also registers the value the hardware
|
||||
// table yields rather than a literal the registrar never emits on a modern machine.
|
||||
("trilinear", "1"),
|
||||
// `anisotropic` registers `"1"` — off — and here the registrar's string IS the answer: it is
|
||||
// **not** one of `hwDetect`'s sixteen (scan of `[0x639a60, 0x639b80)`: sixteen record-pointer
|
||||
// reads, `0xc7f2e4` absent), so nothing overwrites it on any path.
|
||||
("anisotropic", "1"),
|
||||
// **Render scale** (decision 1639) — benilla's own CVar, no 1.12 counterpart, in the
|
||||
// `boothHalfRate` / `SoundOutputLimiter` mould: the reference has no such dial because it has
|
||||
// no second buffer to hang one on. The world renders into the composite lane's off-screen image
|
||||
@@ -394,7 +420,12 @@ pub(crate) struct CvarPlugin;
|
||||
impl Plugin for CvarPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<CvarPersist>()
|
||||
.add_systems(Startup, load_config.in_set(CvarLoad))
|
||||
.add_systems(
|
||||
Startup,
|
||||
(load_config, publish_filter_policy)
|
||||
.chain()
|
||||
.in_set(CvarLoad),
|
||||
)
|
||||
.add_systems(Update, sync_cvars);
|
||||
// **The flush is on the exit edge, not beside its feed** (decision 1528). It used to be
|
||||
// `(sync_cvars, save_config).chain()` in `Update`, which made the "or the app exiting"
|
||||
@@ -433,6 +464,7 @@ pub(crate) struct KnobParams<'w> {
|
||||
follow: ResMut<'w, FollowConfig>,
|
||||
video: ResMut<'w, VideoConfig>,
|
||||
render_scale: ResMut<'w, RenderScale>,
|
||||
tex_filter: ResMut<'w, benilla_assets::TexFilterSetting>,
|
||||
pane_rate: ResMut<'w, PaneRate>,
|
||||
guild_notify: ResMut<'w, crate::ui_guild::GuildMemberNotify>,
|
||||
}
|
||||
@@ -451,6 +483,7 @@ impl KnobParams<'_> {
|
||||
scale: &mut self.scale,
|
||||
view: &mut self.view,
|
||||
msaa: &mut self.msaa,
|
||||
msaa_formats: &self.msaa_formats,
|
||||
look: &mut self.look,
|
||||
click: &mut self.click,
|
||||
loot: &mut self.loot,
|
||||
@@ -463,6 +496,7 @@ impl KnobParams<'_> {
|
||||
follow: &mut self.follow,
|
||||
video: &mut self.video,
|
||||
render_scale: &mut self.render_scale,
|
||||
tex_filter: &mut self.tex_filter,
|
||||
pane_rate: &mut self.pane_rate,
|
||||
guild_notify: &mut self.guild_notify,
|
||||
}
|
||||
@@ -476,6 +510,8 @@ struct Knobs<'a> {
|
||||
scale: &'a mut UiScaleCvar,
|
||||
view: &'a mut ViewDistance,
|
||||
msaa: &'a mut MsaaSetting,
|
||||
/// What the device actually offers — the ceiling `gxMultisample` is clamped to (1643).
|
||||
msaa_formats: &'a benilla_world::view::MsaaFormats,
|
||||
look: &'a mut LookConfig,
|
||||
click: &'a mut ClickConfig,
|
||||
loot: &'a mut LootConfig,
|
||||
@@ -488,6 +524,7 @@ struct Knobs<'a> {
|
||||
follow: &'a mut FollowConfig,
|
||||
video: &'a mut VideoConfig,
|
||||
render_scale: &'a mut RenderScale,
|
||||
tex_filter: &'a mut benilla_assets::TexFilterSetting,
|
||||
pane_rate: &'a mut PaneRate,
|
||||
guild_notify: &'a mut crate::ui_guild::GuildMemberNotify,
|
||||
}
|
||||
@@ -597,13 +634,72 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
|
||||
// Writing the knob live is faithful, not a bug: the CVar holds the PENDING value (latched),
|
||||
// and nothing reads this resource after the world camera's spawn.
|
||||
"gxmultisample" => {
|
||||
knobs.msaa.samples = (v as u32).clamp(*MSAA_RANGE.start(), *MSAA_RANGE.end())
|
||||
// TWO ceilings, and the second one was missing until 1643. The reference's own
|
||||
// `atoi`-then-clamp `[1, 16]` comes first; then the DEVICE's, because a count this
|
||||
// GPU does not offer is not a setting that degrades — it is a wgpu validation error
|
||||
// that kills the render thread on frame one ("Sample count 8 is not supported by
|
||||
// format Rgba16Float on this device", 2026-08-26).
|
||||
//
|
||||
// `MsaaSupportPlugin::finish` already clamped, but it runs once, before the first
|
||||
// update — so it saw `MsaaSetting::default()` and never the value `load_config` was
|
||||
// about to fold in from `config.toml`. A config written on a machine that offers 8x
|
||||
// and opened on one that stops at 4 therefore reached the camera untouched. Clamping
|
||||
// at the WRITE covers every writer there is: the file, a Lua `SetCVar`, the dropdown,
|
||||
// and the Defaults button.
|
||||
let asked = (v as u32).clamp(*MSAA_RANGE.start(), *MSAA_RANGE.end());
|
||||
let granted = knobs.msaa_formats.clamp(asked);
|
||||
if granted != asked {
|
||||
// At `warn`, the same posture as the seed clamp: the player asked for something
|
||||
// and did not get it, and this is the only place that fact exists.
|
||||
warn!(
|
||||
"cvar {name}: this GPU does not offer {asked}x multisampling — using {granted}x"
|
||||
);
|
||||
}
|
||||
knobs.msaa.samples = granted;
|
||||
}
|
||||
// The filter policy's two halves. Both write the PENDING value — latched, like
|
||||
// `gxMultisample`: the process policy is published once at the end of `load_config` and
|
||||
// nothing reads this resource afterwards. `anisotropic` takes the reference's own
|
||||
// parse-then-clamp `[1, 16]` (`0x689110`); `trilinear` is a flag like every other.
|
||||
"trilinear" => knobs.tex_filter.trilinear = v != 0.0,
|
||||
"anisotropic" => {
|
||||
knobs.tex_filter.aniso = (v as u32).clamp(
|
||||
*benilla_assets::ANISO_RANGE.start(),
|
||||
*benilla_assets::ANISO_RANGE.end(),
|
||||
)
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Freeze the texture filter policy for the process, and say what it resolved to.
|
||||
///
|
||||
/// **A separate system, chained after [`load_config`], deliberately.** `load_config` returns early
|
||||
/// on an absent or malformed file, and the policy has to be published on every one of those paths:
|
||||
/// the sampler lanes are an async `AssetLoader` and a set of ordinary systems, none of which can
|
||||
/// read a resource the others own, so a run that never published would be reading
|
||||
/// [`benilla_assets::tex_filter`]'s fallback while a player's `config.toml` said otherwise.
|
||||
///
|
||||
/// The log line is not decoration — it is the same reasoning as `video::log_display_session`
|
||||
/// (1627). Every filtering report this client will get comes from a machine nobody here can run,
|
||||
/// and "which mode was that run actually in" must be readable off the log a player pastes rather
|
||||
/// than reasoned about.
|
||||
fn publish_filter_policy(filter: Res<benilla_assets::TexFilterSetting>) {
|
||||
benilla_assets::publish_tex_filter(*filter);
|
||||
let mode = filter.mode();
|
||||
let name = match mode {
|
||||
3 => "bilinear + nearest-mip select, aniso off",
|
||||
4 => "trilinear, aniso off",
|
||||
_ => "trilinear + aniso",
|
||||
};
|
||||
info!(
|
||||
"texture filter: mode {mode} ({name}) — trilinear={} anisotropic={}",
|
||||
u8::from(filter.trilinear),
|
||||
filter.aniso
|
||||
);
|
||||
}
|
||||
|
||||
/// A stored minimap zoom level → a valid index: truncate to int and clamp into
|
||||
/// `[0, MINIMAP_ZOOM_LEVELS)`, the client's own `set_zoom` clamp.
|
||||
fn zoom_index(v: f32) -> u8 {
|
||||
@@ -631,6 +727,15 @@ fn load_config(mut persist: ResMut<CvarPersist>, mut params: KnobParams) {
|
||||
if crate::video::novsync_env() {
|
||||
persist.env_overridden.insert("gxvsync".into());
|
||||
}
|
||||
// The filter policy's A/B levers, under the same law: pricing mode 3 against mode 5 on one
|
||||
// machine in one session is exactly what these are for, and a value that stuck in
|
||||
// `config.toml` would silently denominate every later reading.
|
||||
if std::env::var_os("WOW_TRILINEAR").is_some() {
|
||||
persist.env_overridden.insert("trilinear".into());
|
||||
}
|
||||
if std::env::var_os("WOW_ANISO").is_some() {
|
||||
persist.env_overridden.insert("anisotropic".into());
|
||||
}
|
||||
// `$WOW_WIN`, a capture scenario, or any instrumented run owns the window's geometry for the
|
||||
// session (decision 1627), so the two CVars that would otherwise move it mid-run are
|
||||
// session-only under exactly the same law as the four above.
|
||||
@@ -773,6 +878,7 @@ fn sync_cvars(
|
||||
guild_notify,
|
||||
msaa,
|
||||
msaa_formats,
|
||||
tex_filter,
|
||||
} = ¶ms;
|
||||
// The config file's values go in FIRST (decision 1291): registration — ours below, or an
|
||||
// addon's `RegisterCVar` later — starts a key at its saved value. This is what carries a
|
||||
@@ -805,7 +911,7 @@ fn sync_cvars(
|
||||
.collect(),
|
||||
);
|
||||
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
|
||||
let session: [(&str, String); 37] = [
|
||||
let session: [(&str, String); 39] = [
|
||||
("MasterVolume", sound.master.to_string()),
|
||||
("SoundVolume", sound.sfx.to_string()),
|
||||
("MusicVolume", sound.music.to_string()),
|
||||
@@ -857,6 +963,8 @@ fn sync_cvars(
|
||||
("boothHalfRate", flag(pane_rate.half)),
|
||||
("renderScale", render_scale.0.to_string()),
|
||||
("gxMultisample", msaa.samples.to_string()),
|
||||
("trilinear", flag(tex_filter.trilinear)),
|
||||
("anisotropic", tex_filter.aniso.to_string()),
|
||||
];
|
||||
for (name, value) in session {
|
||||
script.set_cvar_host(name, &value);
|
||||
@@ -1149,8 +1257,19 @@ mod tests {
|
||||
let mut guild_notify = crate::ui_guild::GuildMemberNotify::default();
|
||||
// Literal, not Default: MsaaSetting::default() reads $WOW_MSAA.
|
||||
let mut msaa = MsaaSetting { samples: 1 };
|
||||
// What an Apple GPU answers for the trio we render into (Rgba16Float / Depth32Float /
|
||||
// the swapchain). 8 and 16 are NOT in it — which is the whole point below.
|
||||
let msaa_formats = benilla_world::view::MsaaFormats {
|
||||
formats: vec![(32, 32, 1), (32, 32, 2), (32, 32, 4)],
|
||||
};
|
||||
// Literal for the same reason: RenderScale::default() reads $WOW_RENDER_SCALE.
|
||||
let mut render_scale = RenderScale(1.0);
|
||||
// Literal for the same reason again (1642): TexFilterSetting::default() reads
|
||||
// $WOW_TRILINEAR / $WOW_ANISO. These are what ships (1645).
|
||||
let mut tex_filter = benilla_assets::TexFilterSetting {
|
||||
trilinear: true,
|
||||
aniso: 1,
|
||||
};
|
||||
let mut knobs = Knobs {
|
||||
sound: &mut sound,
|
||||
scale: &mut scale,
|
||||
@@ -1170,6 +1289,8 @@ mod tests {
|
||||
pane_rate: &mut pane_rate,
|
||||
guild_notify: &mut guild_notify,
|
||||
msaa: &mut msaa,
|
||||
tex_filter: &mut tex_filter,
|
||||
msaa_formats: &msaa_formats,
|
||||
};
|
||||
assert!(apply_to_knobs("MusicVolume", "0.7", &mut knobs));
|
||||
assert_eq!(knobs.sound.music, 0.7);
|
||||
@@ -1182,8 +1303,33 @@ mod tests {
|
||||
// does — the value reaching the camera is a sample COUNT, where 1 is none (1629).
|
||||
assert!(apply_to_knobs("gxMultisample", "4", &mut knobs));
|
||||
assert_eq!(knobs.msaa.samples, 4);
|
||||
// The filter policy's two rows: `anisotropic` takes the reference's own [1, 16] clamp,
|
||||
// `trilinear` is a flag. Both write the pending value; the process policy is already
|
||||
// published by the time either can be typed (1642).
|
||||
assert!(apply_to_knobs("anisotropic", "99", &mut knobs));
|
||||
assert_eq!(knobs.tex_filter.aniso, *benilla_assets::ANISO_RANGE.end());
|
||||
assert!(apply_to_knobs("anisotropic", "0", &mut knobs));
|
||||
assert_eq!(knobs.tex_filter.aniso, *benilla_assets::ANISO_RANGE.start());
|
||||
// Both directions: the knob starts at what ships (on), so only the flip to 0 proves the
|
||||
// arm does anything.
|
||||
assert!(apply_to_knobs("trilinear", "0", &mut knobs));
|
||||
assert!(!knobs.tex_filter.trilinear);
|
||||
assert!(apply_to_knobs("trilinear", "1", &mut knobs));
|
||||
assert!(knobs.tex_filter.trilinear);
|
||||
// **The DEVICE's ceiling, not the reference's** (decision 1643). 99 clamps to the
|
||||
// reference's 16 and then to the 4 this GPU offers — before 1643 it stopped at 16 and the
|
||||
// camera was handed a sample count wgpu refuses, killing the render thread on frame one.
|
||||
assert!(apply_to_knobs("gxmultisample", "99", &mut knobs));
|
||||
assert_eq!(knobs.msaa.samples, *MSAA_RANGE.end());
|
||||
assert_eq!(knobs.msaa.samples, 4);
|
||||
// The realistic route in: a config written where 8x exists, opened where it does not.
|
||||
assert!(apply_to_knobs("gxMultisample", "8", &mut knobs));
|
||||
assert_eq!(
|
||||
knobs.msaa.samples, 4,
|
||||
"a device that stops at 4x must never be handed an 8"
|
||||
);
|
||||
// A count the device DOES offer is untouched.
|
||||
assert!(apply_to_knobs("gxmultisample", "2", &mut knobs));
|
||||
assert_eq!(knobs.msaa.samples, 2);
|
||||
assert!(apply_to_knobs("gxmultisample", "0", &mut knobs));
|
||||
assert_eq!(knobs.msaa.samples, *MSAA_RANGE.start());
|
||||
// Render scale takes a fraction and clamps to its own range at both ends (1639).
|
||||
@@ -1308,7 +1454,9 @@ mod tests {
|
||||
#[test]
|
||||
fn a_lua_setcvar_lands_in_config_toml_end_to_end() {
|
||||
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp = std::env::temp_dir().join(format!("benilla-cvar-e2e-{}", std::process::id()));
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
let _c = EnvGuard::unset("WOW_CAPTURE");
|
||||
@@ -1365,6 +1513,12 @@ mod tests {
|
||||
.insert_resource(UiScaleCvar(DEFAULT_UI_SCALE))
|
||||
.insert_resource(ViewDistance { farclip: 350.0 })
|
||||
.insert_resource(MsaaSetting { samples: 1 })
|
||||
// Literal for the same reason (1642): TexFilterSetting::default() reads
|
||||
// $WOW_TRILINEAR / $WOW_ANISO. These are what ships (1645).
|
||||
.insert_resource(benilla_assets::TexFilterSetting {
|
||||
trilinear: true,
|
||||
aniso: 1,
|
||||
})
|
||||
// The device menu the Video dropdown reads. A real-shaped list, not empty: these
|
||||
// tests exercise `GetCurrentMultisampleFormat`'s lookup, which needs rows to find.
|
||||
.insert_resource(benilla_world::view::MsaaFormats {
|
||||
@@ -1403,7 +1557,9 @@ mod tests {
|
||||
fn entering_the_world_survives_the_quit_and_comes_back_selected() {
|
||||
use crate::char_select::{ClientState, Roster};
|
||||
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp = std::env::temp_dir().join(format!("benilla-lastchar-{}", std::process::id()));
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
let _c = EnvGuard::unset("WOW_CAPTURE");
|
||||
@@ -1477,7 +1633,9 @@ mod tests {
|
||||
#[test]
|
||||
fn a_minimap_setzoom_reaches_the_knob_and_the_file() {
|
||||
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp = std::env::temp_dir().join(format!("benilla-mmzoom-{}", std::process::id()));
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
let _c = EnvGuard::unset("WOW_CAPTURE");
|
||||
|
||||
@@ -362,7 +362,9 @@ mod tests {
|
||||
/// this one owns is 0954's layout table, unchanged by 1175's move.
|
||||
#[test]
|
||||
fn the_home_law_override_then_the_residents_and_hermetic_captures() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp = std::env::temp_dir().join(format!("benilla-ls-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
|
||||
@@ -459,7 +461,9 @@ mod tests {
|
||||
/// changed.
|
||||
#[test]
|
||||
fn the_state_folder_no_longer_depends_on_finding_the_install() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _c = EnvGuard::unset("WOW_CAPTURE");
|
||||
let _h = EnvGuard::unset("BENILLA_HOME");
|
||||
let _d = EnvGuard::set("WOW_DATA", "/nonexistent/benilla-test/Data");
|
||||
@@ -483,7 +487,9 @@ mod tests {
|
||||
/// whether it runs in the primary or in a slot.
|
||||
#[test]
|
||||
fn the_state_folder_lands_where_the_build_says() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _c = EnvGuard::unset("WOW_CAPTURE");
|
||||
let _h = EnvGuard::unset("BENILLA_HOME");
|
||||
let h = home().expect("home() always resolves outside a capture");
|
||||
@@ -519,7 +525,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn write_atomic_creates_dirs_and_replaces_whole_files() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp = std::env::temp_dir().join(format!("benilla-wa-{}", std::process::id()));
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
let path = tmp.join("nested/config.toml");
|
||||
|
||||
@@ -22,7 +22,9 @@ fn macro_view(name: &str, body: &str) -> MacroView {
|
||||
/// macros back — the whole persistence loop over the real `local_state` law.
|
||||
#[test]
|
||||
fn a_saved_macro_table_round_trips_through_benilla_macros() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp = std::env::temp_dir().join(format!("benilla-macros-{}", std::process::id()));
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
let _c = EnvGuard::unset("WOW_CAPTURE");
|
||||
@@ -57,7 +59,9 @@ fn a_saved_macro_table_round_trips_through_benilla_macros() {
|
||||
/// a capture is session-only and nothing is written under anyone's install.
|
||||
#[test]
|
||||
fn a_capture_run_persists_nothing() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _h = EnvGuard::set("BENILLA_HOME", "/tmp/benilla-should-not-exist");
|
||||
let _c = EnvGuard::set("WOW_CAPTURE", "ui-macro");
|
||||
assert_eq!(crate::local_state::macros_account_path(), None);
|
||||
|
||||
@@ -129,7 +129,9 @@ mod tests {
|
||||
/// VM's value is replaced by the saved one — then `VARIABLES_LOADED` fires, once, after it.
|
||||
#[test]
|
||||
fn the_file_round_trips_through_the_folder_and_then_fires_variables_loaded() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp = std::env::temp_dir().join(format!("benilla-sv-{}", std::process::id()));
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
let _c = EnvGuard::unset("WOW_CAPTURE");
|
||||
@@ -165,7 +167,9 @@ mod tests {
|
||||
/// `BENILLA_HOME` pointing somewhere writable (0954's law, and the reason captures are stable).
|
||||
#[test]
|
||||
fn a_capture_run_neither_reads_nor_writes() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp = std::env::temp_dir().join(format!("benilla-svcap-{}", std::process::id()));
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
|
||||
@@ -184,7 +188,9 @@ mod tests {
|
||||
/// Nothing registered = the UI never loaded: writing would be a wipe, so it is skipped.
|
||||
#[test]
|
||||
fn an_empty_declaration_set_writes_nothing() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp = std::env::temp_dir().join(format!("benilla-svempty-{}", std::process::id()));
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
let _c = EnvGuard::unset("WOW_CAPTURE");
|
||||
|
||||
@@ -795,8 +795,9 @@ mod differential_tests {
|
||||
.chars()
|
||||
.filter_map(|c| e.char_cell(face, ppem, c))
|
||||
.flat_map(|c| c.glyphs.iter())
|
||||
.map(|g| (g.advance.floor() + step_extra) / dpi)
|
||||
.sum();
|
||||
.map(|g| g.advance.floor() + step_extra)
|
||||
.sum::<f32>()
|
||||
/ dpi;
|
||||
assert_eq!(
|
||||
got, by_glyph,
|
||||
"{family} @{ppem} dpi {dpi} extra {step_extra}: {text:?} — the \
|
||||
|
||||
@@ -73,11 +73,16 @@ pub(super) fn resolve(e: &mut TextEngine, font: &FontSpec) -> Resolved {
|
||||
/// The caller must have ensured `text`'s metrics; a character that has not been is skipped rather
|
||||
/// than shaped, because this half is deliberately `&`-only.
|
||||
pub(super) fn measure_line_width(e: &TextEngine, r: Resolved, step_extra: f32, text: &str) -> f32 {
|
||||
let dpi = e.dpi();
|
||||
text.chars()
|
||||
// Summed in DEVICE px and divided once, because that is exactly what the render pen does
|
||||
// (decision 1644 — it walks in device px so the line's origin can round once). A sum of
|
||||
// quotients and a quotient of sums are not the same `f32`, and "measure == render, to the bit"
|
||||
// is a property this module is asked to keep, not one it gets to approximate.
|
||||
let steps: f32 = text
|
||||
.chars()
|
||||
.filter_map(|c| e.char_cell(r.face, r.ppem, c))
|
||||
.map(|c| (c.floor_sum + step_extra * c.glyphs.len() as f32) / dpi)
|
||||
.sum()
|
||||
.map(|c| c.floor_sum + step_extra * c.glyphs.len() as f32)
|
||||
.sum();
|
||||
steps / e.dpi()
|
||||
}
|
||||
|
||||
/// The width law itself, for the differential test that pins it to a shaped sum.
|
||||
@@ -385,16 +390,18 @@ pub(crate) fn line_advances(e: &mut TextEngine, text: &str, font: FontSpec) -> V
|
||||
|
||||
let mut written = vec![false; text.len() + 1];
|
||||
written[0] = true;
|
||||
let mut x = 0.0f32;
|
||||
// The same device-px pen the render walks (1644), so a caret boundary is the same number the
|
||||
// glyph before it was drawn at.
|
||||
let mut x_dev = 0.0f32;
|
||||
for (off, ch) in drawn.char_indices() {
|
||||
if let Some(c) = e.char_cell(r.face, r.ppem, ch) {
|
||||
for g in &c.glyphs {
|
||||
x += client_step(g.advance, r.step_extra, dpi);
|
||||
x_dev += client_step(g.advance, r.step_extra);
|
||||
}
|
||||
}
|
||||
// This character's end boundary in DRAWN bytes → the RAW boundary it sits on.
|
||||
let end = bounds[(off + ch.len_utf8()).min(drawn.len())];
|
||||
cum[end] = x;
|
||||
cum[end] = x_dev / dpi;
|
||||
written[end] = true;
|
||||
}
|
||||
// Forward-fill the unwritten slots (cluster interiors, markup bytes): each carries the
|
||||
|
||||
@@ -81,9 +81,10 @@ pub(crate) struct FontSpec<'a> {
|
||||
/// only-negative kerning scaled by the face's advance-adjust, then rounds). This extra tracking is
|
||||
/// look-defining — it is why real client text reads wider/denser than the raw font metrics.
|
||||
///
|
||||
/// **Applied in PHYSICAL device pixels** (the ppem the face is actually rasterized at), then divided
|
||||
/// back to logical — because that is where the real client's law lives: `FT_advance >> 6` floors the
|
||||
/// device-pixel advance and the `+1`/`+1` biases are whole *device* pixels. Flooring in logical px
|
||||
/// **Answered in PHYSICAL device pixels** (the ppem the face is actually rasterized at); the caller
|
||||
/// divides back to logical **once**, at the end of the line (decision 1644) — because that is where
|
||||
/// the real client's law lives: `FT_advance >> 6` floors the device-pixel advance and the `+1`/`+1`
|
||||
/// biases are whole *device* pixels. Flooring in logical px
|
||||
/// instead (the pre-fix shortcut) over-tracks on any window where `dpi ≠ 1`: at retina a
|
||||
/// 6.385px-logical (12.77px-physical) ARIALN digit steps `(⌊12.77⌋+2)/2 = 7.0px`, not the logical
|
||||
/// `⌊6.385⌋+2 = 8.0px` — a full logical pixel of extra gap *per digit*, proportionally huge on small
|
||||
@@ -92,8 +93,8 @@ pub(crate) struct FontSpec<'a> {
|
||||
/// Deliberate v1 simplification (module doc): kerning is dropped entirely (the client applies only
|
||||
/// *negative* pair kerns and rounds the sum — at UI sizes the rounded contribution is almost always
|
||||
/// 0).
|
||||
fn client_step(raw_physical_advance: f32, step_extra: f32, dpi: f32) -> f32 {
|
||||
(raw_physical_advance.floor() + step_extra) / dpi
|
||||
fn client_step(raw_physical_advance: f32, step_extra: f32) -> f32 {
|
||||
raw_physical_advance.floor() + step_extra
|
||||
}
|
||||
|
||||
/// The step-law bias for a font's outline flag: the base `+1` for everyone, and one more `+1` for
|
||||
@@ -236,17 +237,26 @@ fn layout_text_quads_inner(
|
||||
let e = &*e;
|
||||
let dpi = e.dpi();
|
||||
let sheet = e.sheet_image();
|
||||
// Snap a logical coordinate to the nearest DEVICE pixel (a multiple of `1/dpi`), so glyph edges
|
||||
// land on device pixels — the same integer-device-pixel snap the real client does, which keeps
|
||||
// the cell crisp instead of resampled. A cell is an integer count of device texels, so the
|
||||
// quad's far edge is then automatically device-aligned too.
|
||||
// **The line rounds ONCE, and the glyphs are integer device offsets from it** (decision 1644).
|
||||
//
|
||||
// Note what this now buys that it could not before: every glyph on a line shares one `pen_y`
|
||||
// and an integer physical `bearing_top`, so they all round by the same residual and the line is
|
||||
// FLAT. Under the old size ladder a rescale ran after this and multiplied each already-rounded
|
||||
// position by a non-integer factor, giving every letter its own sub-pixel phase — which is what
|
||||
// "the letters aren't on a line" was (decision 1342).
|
||||
let snap = |v: f32| (v * dpi).round() / dpi;
|
||||
// Glyph edges must land on device pixels — the same integer-device-pixel placement the real
|
||||
// client does — or the cell is resampled instead of blitted. Everything a glyph contributes to
|
||||
// its own position is *already* an integer count of device texels: swash's `bearing_x` /
|
||||
// `bearing_top`, the shaper's `y_off`, and the step law's `floor(advance) + extra`. So the pen
|
||||
// walks in DEVICE px, the line's origin is rounded once, and each glyph adds its integers to
|
||||
// that one rounded number.
|
||||
//
|
||||
// The obvious spelling — `snap(pen + per_glyph_offset)` with `snap(v) = (v·dpi).round()/dpi` —
|
||||
// is the same thing in exact arithmetic and NOT the same thing in `f32`, which is what B232
|
||||
// was. At a fractional scale factor (Windows 150 %, a fractional Wayland scale) `bearing/dpi`
|
||||
// is inexact, so a sum that is mathematically a half-pixel tie lands either side of the tie
|
||||
// depending on the glyph's own bearing: the tall letters round one way, the x-height letters
|
||||
// the other, and the word visibly steps. It fires only where the tie actually falls (~2 % of
|
||||
// seats at dpi 1.5, ~4 % at 1.75 — measured), which is why it read as "one UI scale is broken
|
||||
// and the next is fine" rather than as anything systematic. Rounding the ORIGIN instead of the
|
||||
// sum removes the tie from the per-glyph term entirely: a line cannot be anything but flat.
|
||||
// (The predecessor defect, 1342's: a rescale ran *after* the snap and multiplied each rounded
|
||||
// position by a non-integer factor, giving every letter its own sub-pixel phase.)
|
||||
|
||||
// The render lays the same lines the measure counted, or a MIDDLE-justified block seats
|
||||
// against a height it does not have ([`fontstring_lines`], decision 1343).
|
||||
@@ -325,10 +335,12 @@ fn layout_text_quads_inner(
|
||||
let mut pen_y = block_top + baseline_in_cell;
|
||||
|
||||
// One glyph laid at a line-relative x-origin of 0, pending the line's justification shift.
|
||||
// Both offsets are **device px, and integral** — the pen's own walk plus swash's bearings.
|
||||
struct PendingGlyph {
|
||||
x_rel: f32,
|
||||
/// Vertical offset from the line baseline (`pen_y`).
|
||||
y_rel: f32,
|
||||
/// Horizontal offset from the line's rounded origin.
|
||||
x_dev: f32,
|
||||
/// Vertical offset from the line's rounded baseline.
|
||||
y_dev: f32,
|
||||
uv: Rect,
|
||||
px_w: f32,
|
||||
px_h: f32,
|
||||
@@ -347,12 +359,14 @@ fn layout_text_quads_inner(
|
||||
// This line's hyperlink x-ranges (line-origin space), one entry per distinct link — a
|
||||
// link's runs are contiguous, so min/max of its runs' pen extents is its span.
|
||||
let mut line_links: Vec<(std::sync::Arc<super::markup::LinkInfo>, f32, f32)> = Vec::new();
|
||||
let mut pen_x = 0.0f32;
|
||||
// The pen in DEVICE px: every step is `floor(advance) + extra`, an integer, so the walk
|
||||
// is exact and the logical width is one division at the end rather than a sum of quotients.
|
||||
let mut pen_dev = 0.0f32;
|
||||
for run in line {
|
||||
if run.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let run_x0 = pen_x;
|
||||
let run_x0 = pen_dev / dpi;
|
||||
// The pen walks CHARACTERS, not a shaped buffer. The client's law has no kerning and no
|
||||
// neighbour term (`ComputeStep 0x5ca2d0`), so a run's glyph sequence is the
|
||||
// concatenation of its characters' — which is exactly what the cache holds, and exactly
|
||||
@@ -373,15 +387,15 @@ fn layout_text_quads_inner(
|
||||
for g in &cc.glyphs {
|
||||
if let Some(info) = e.cell(r.face, r.ppem, r.radius, g.glyph_id) {
|
||||
pending.push(PendingGlyph {
|
||||
x_rel: pen_x + info.bearing_x / dpi,
|
||||
y_rel: g.y_off / dpi - info.bearing_top / dpi - thick_rise,
|
||||
x_dev: pen_dev + info.bearing_x,
|
||||
y_dev: g.y_off - info.bearing_top,
|
||||
uv: info.uv,
|
||||
px_w: info.px_w / dpi,
|
||||
px_h: info.px_h / dpi,
|
||||
color,
|
||||
});
|
||||
}
|
||||
pen_x += client_step(g.advance, r.step_extra, dpi);
|
||||
pen_dev += client_step(g.advance, r.step_extra);
|
||||
}
|
||||
char_index += 1;
|
||||
}
|
||||
@@ -390,22 +404,27 @@ fn layout_text_quads_inner(
|
||||
.iter_mut()
|
||||
.find(|(a, _, _)| std::sync::Arc::ptr_eq(a, info))
|
||||
{
|
||||
Some((_, _, x1)) => *x1 = pen_x,
|
||||
None => line_links.push((info.clone(), run_x0, pen_x)),
|
||||
Some((_, _, x1)) => *x1 = pen_dev / dpi,
|
||||
None => line_links.push((info.clone(), run_x0, pen_dev / dpi)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: shift the whole line by the justification offset and emit.
|
||||
let line_width = pen_x;
|
||||
let line_width = pen_dev / dpi;
|
||||
let origin_x = match justify.h {
|
||||
JustifyH::Left => rect.min.x,
|
||||
JustifyH::Center => rect.min.x + (rect.width() - line_width) * 0.5,
|
||||
JustifyH::Right => rect.max.x - line_width,
|
||||
};
|
||||
// The line's two rounded device anchors — computed once, shared by every glyph on it.
|
||||
// `thick_rise` is a LOGICAL px (the composite cell's net blit shift), so it rides into the
|
||||
// round with the baseline rather than being applied to each glyph after it.
|
||||
let origin_dev_x = (origin_x * dpi).round();
|
||||
let baseline_dev_y = ((pen_y - thick_rise) * dpi).round();
|
||||
for g in &pending {
|
||||
let gx = snap(origin_x + g.x_rel);
|
||||
let gy = snap(pen_y + g.y_rel);
|
||||
let gx = (origin_dev_x + g.x_dev) / dpi;
|
||||
let gy = (baseline_dev_y + g.y_dev) / dpi;
|
||||
quads.push(UiQuad {
|
||||
rect: Rect::new(gx, gy, gx + g.px_w, gy + g.px_h),
|
||||
z_key,
|
||||
@@ -567,6 +586,83 @@ mod measure_fits_render {
|
||||
}
|
||||
}
|
||||
|
||||
/// **One line is ONE baseline — at every DPI, size and seat.**
|
||||
///
|
||||
/// The emit pass's whole claim (decision 1342) is that a line is flat *by construction*: one
|
||||
/// `pen_y`, an integer physical `bearing_top` per glyph, so every letter rounds by the same
|
||||
/// residual. That is true in exact arithmetic and it was NOT true in `f32` — `snap(pen_y −
|
||||
/// bt/dpi)` rounds a per-glyph sum, and at a **fractional** scale factor (Windows 150 %,
|
||||
/// a fractional Wayland/KDE scale) `bt/dpi` is inexact, so a value that is mathematically a
|
||||
/// half-pixel tie lands either side of it depending on the glyph's own bearing. B232's
|
||||
/// "the letters are not vertically aligned" is that tie, one device pixel wide, and it can
|
||||
/// only be seen at the sizes and seats where the tie actually falls — which is why one UI
|
||||
/// scale showed it and the next did not.
|
||||
///
|
||||
/// The invariant is read off the quads: FRIZQT's hinted raster puts **every** cell bottom
|
||||
/// exactly on the baseline (`px_h == bearing_top` for every glyph at every ppem 8..64 —
|
||||
/// measured, engine sweep), so one baseline means one `rect.max.y`.
|
||||
#[test]
|
||||
fn one_line_is_one_baseline_at_every_dpi() {
|
||||
let Some(mut e) = test_engine(1.0) else {
|
||||
eprintln!("skipping: no install / font chain");
|
||||
return;
|
||||
};
|
||||
// Integer DPIs are the ones this machine can capture; the fractional ones are what the
|
||||
// reporters run, and they are exactly where the tie lives.
|
||||
for dpi in [1.0f32, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0] {
|
||||
e.set_dpi_for_test(dpi);
|
||||
for base in [10.0f32, 12.0, 13.0, 14.0, 16.0, 18.0, 20.0] {
|
||||
let s = spec(base * ERA);
|
||||
// A quarter-pixel walk over the seat: a frame at 90 % UI scale seats its text at
|
||||
// every fractional offset there is.
|
||||
for step in 0..320 {
|
||||
let top = step as f32 * 0.25;
|
||||
let rect = Rect::new(17.5, top, 217.5, top + 20.0);
|
||||
let quads = layout_text_quads(
|
||||
&mut e,
|
||||
"Combat",
|
||||
rect,
|
||||
[1.0; 4],
|
||||
Justify {
|
||||
h: JustifyH::Left,
|
||||
v: JustifyV::Middle,
|
||||
},
|
||||
0,
|
||||
s,
|
||||
);
|
||||
let lo = quads
|
||||
.iter()
|
||||
.map(|q| q.rect.max.y)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let hi = quads
|
||||
.iter()
|
||||
.map(|q| q.rect.max.y)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
assert!(
|
||||
(hi - lo) * dpi < 1e-3,
|
||||
"dpi {dpi}, height {base}, seat {top}: the letters of \"Combat\" \
|
||||
straddle {:.2} device px of baseline",
|
||||
(hi - lo) * dpi
|
||||
);
|
||||
// The same law horizontally: a glyph's left edge is the line's one rounded
|
||||
// origin plus integers, so it is on the device grid too (a letter off it is
|
||||
// resampled — the "uneven stems" half of the same report).
|
||||
for q in &quads {
|
||||
for (axis, dev) in
|
||||
[("top", q.rect.min.y * dpi), ("left", q.rect.min.x * dpi)]
|
||||
{
|
||||
assert!(
|
||||
(dev - dev.round()).abs() < 1e-3,
|
||||
"dpi {dpi}, height {base}, seat {top}: a glyph {axis} landed at \
|
||||
{dev} device px — off the device grid"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **The pen and the measure are the same number, to the bit.** The emit pass walks characters
|
||||
/// and steps; `measure_line_width` sums the same per-character steps. Nothing rounds between
|
||||
/// them, so this is equality, not a tolerance — and any future divergence (a re-introduced
|
||||
|
||||
@@ -283,25 +283,35 @@ fn check_window_pinned(
|
||||
let Ok(window) = windows.single() else {
|
||||
return;
|
||||
};
|
||||
let got = UVec2::new(
|
||||
window.resolution.width() as u32,
|
||||
window.resolution.height() as u32,
|
||||
);
|
||||
// `$WOW_DPI` re-denominates `$WOW_WIN` in PHYSICAL px (that knob's doc says so: the capture is
|
||||
// the framebuffer that player's GPU would scan out), so the size this compares has to follow
|
||||
// it. Comparing the logical size under an override refused every non-1× run outright — it read
|
||||
// `want` against `want/dpi`.
|
||||
let res = &window.resolution;
|
||||
let got = match requested_dpi() {
|
||||
Some(_) => UVec2::new(res.physical_width(), res.physical_height()),
|
||||
None => UVec2::new(res.width() as u32, res.height() as u32),
|
||||
};
|
||||
if got == want {
|
||||
return;
|
||||
}
|
||||
let unit = if requested_dpi().is_some() {
|
||||
"physical"
|
||||
} else {
|
||||
"logical"
|
||||
};
|
||||
let capturing =
|
||||
std::env::var_os("WOW_CAPTURE").is_some() || std::env::var_os("WOW_CAPTURE_UI").is_some();
|
||||
if !capturing {
|
||||
warn!(
|
||||
"window: asked for {}x{} logical, got {}x{} — the window manager clamped it to the \
|
||||
"window: asked for {}x{} {unit}, got {}x{} — the window manager clamped it to the \
|
||||
display. Harmless here; it would invalidate a capture.",
|
||||
want.x, want.y, got.x, got.y
|
||||
);
|
||||
return;
|
||||
}
|
||||
error!(
|
||||
"window: REFUSING this capture — asked for {}x{} logical, got {}x{}. The window manager \
|
||||
"window: REFUSING this capture — asked for {}x{} {unit}, got {}x{}. The window manager \
|
||||
clamped the request to the display this window opened on, so the image would not be the \
|
||||
size the scenario is denominated in and any diff against it would be meaningless. Use a \
|
||||
size that fits the current display (or move the window to a bigger one) and re-run.",
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! - [`BlpVariant::WorldArt`] — tiling world/model albedo: `Rgba8Unorm` (the RE'd gamma-space
|
||||
//! invariant — the GPU does *not* linearize albedo on sample, so shader math stays in WoW's
|
||||
//! gamma/byte space), the BLP's **authored** mip pyramid used verbatim (the real 1.12 client cannot
|
||||
//! regenerate mips — it uploads the stored ones), repeat + trilinear + anisotropic sampling.
|
||||
//! regenerate mips — it uploads the stored ones), repeat + the process filter policy
|
||||
//! ([`crate::tex_filter`]).
|
||||
//! - [`BlpVariant::Sprite`] — emissive billboards (sun/moon discs): `Rgba8UnormSrgb`, clamp, mip 0.
|
||||
//! - [`BlpVariant::Cursor`] — the OS cursor image: `Rgba8UnormSrgb`, single mip.
|
||||
//!
|
||||
@@ -26,7 +27,7 @@ use crate::gpu_blp::{for_upload, UploadChain};
|
||||
/// Which on-GPU form a BLP decodes to. See the module docs.
|
||||
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||
pub enum BlpVariant {
|
||||
/// Tiling world/model albedo — `Rgba8Unorm`, authored mips, repeat + anisotropic. The default.
|
||||
/// Tiling world/model albedo — `Rgba8Unorm`, authored mips, repeat + the filter policy. The default.
|
||||
#[default]
|
||||
WorldArt,
|
||||
/// Emissive billboard (celestial disc) — `Rgba8UnormSrgb`, clamp, mip 0 only.
|
||||
@@ -117,8 +118,8 @@ impl AssetLoader for BlpImageLoader {
|
||||
/// `wow-5875-re/system/lighting`). Every decoded lane below uploads as this.
|
||||
const GAMMA_BYTES: TextureFormat = TextureFormat::Rgba8Unorm;
|
||||
|
||||
/// World/model albedo: the BLP's authored mip pyramid laid in verbatim, repeat + trilinear +
|
||||
/// anisotropic. (Port of the old `repeat_texture_authored`.)
|
||||
/// World/model albedo: the BLP's authored mip pyramid laid in verbatim, repeat + the process
|
||||
/// filter policy ([`crate::tex_filter`]). (Port of the old `repeat_texture_authored`.)
|
||||
fn world_art_image(upload: UploadChain, wrap: (bool, bool)) -> Image {
|
||||
let UploadChain { chain, format } = upload;
|
||||
let levels = chain.mips.len() as u32;
|
||||
@@ -157,13 +158,18 @@ fn world_art_image(upload: UploadChain, wrap: (bool, bool)) -> Image {
|
||||
ImageAddressMode::ClampToEdge
|
||||
}
|
||||
};
|
||||
// The mip filter and the anisotropy are NOT this lane's to choose: the reference forces both
|
||||
// from two process globals at every `TextureCreate` (`0x449ae0`), and what a virgin install
|
||||
// gets from `hwDetect` is trilinear with anisotropy OFF. See [`crate::tex_filter`] — this used
|
||||
// to read `Linear` / `8`, which is mode 5 shipped as if it were the default.
|
||||
let filter = crate::tex_filter::tex_filter();
|
||||
image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
|
||||
address_mode_u: mode(wrap.0),
|
||||
address_mode_v: mode(wrap.1),
|
||||
mag_filter: ImageFilterMode::Linear,
|
||||
min_filter: ImageFilterMode::Linear,
|
||||
mipmap_filter: ImageFilterMode::Linear,
|
||||
anisotropy_clamp: 8,
|
||||
mipmap_filter: filter.mipmap_filter(),
|
||||
anisotropy_clamp: filter.anisotropy_clamp(),
|
||||
..Default::default()
|
||||
});
|
||||
image
|
||||
@@ -265,7 +271,10 @@ fn point_sprite_image(upload: UploadChain) -> Image {
|
||||
address_mode_v: ImageAddressMode::ClampToEdge,
|
||||
mag_filter: ImageFilterMode::Linear,
|
||||
min_filter: ImageFilterMode::Linear,
|
||||
mipmap_filter: ImageFilterMode::Linear,
|
||||
// A mip chain means the policy owns the mip filter here too — the reference's override is
|
||||
// unconditional across every texture it creates, with no per-class exemption. The aniso
|
||||
// stays absent by the argument above, and the policy agrees in modes 3 and 4.
|
||||
mipmap_filter: crate::tex_filter::tex_filter().mipmap_filter(),
|
||||
..Default::default()
|
||||
});
|
||||
image
|
||||
|
||||
@@ -48,6 +48,8 @@ pub use blp::{BlpImageLoader, BlpLoaderSettings, BlpVariant};
|
||||
/// Whether this run's GPU can eat WoW's stored DXT blocks, and the chain form that follows.
|
||||
mod gpu_blp;
|
||||
pub use gpu_blp::{bc_supported, for_upload, publish_bc_support, BlpGpuSupportPlugin, UploadChain};
|
||||
mod tex_filter;
|
||||
pub use tex_filter::{publish_tex_filter, tex_filter, TexFilterSetting, ANISO_RANGE};
|
||||
mod m2;
|
||||
pub use m2::{
|
||||
EmitterBillboard, M2Model, M2ModelLoader, ModelEmitter, ModelLight, ModelRibbon, PortraitCamera,
|
||||
|
||||
@@ -137,13 +137,19 @@ pub(crate) fn layer_array_image(
|
||||
image.data = Some(rgba);
|
||||
image.texture_descriptor.mip_level_count = mip_levels;
|
||||
image.data_order = TextureDataOrder::LayerMajor;
|
||||
// Ground is the surface the filter policy costs the most on — a tiling texture at a grazing
|
||||
// angle is the anisotropy axis's worst case, and it fills most of the frame. The reference
|
||||
// does not let this lane choose: both values come from the process globals
|
||||
// ([`crate::tex_filter`]) — trilinear with anisotropy off, which is what `hwDetect` hands a
|
||||
// virgin install (1645).
|
||||
let filter = crate::tex_filter::tex_filter();
|
||||
image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
|
||||
address_mode_u: ImageAddressMode::Repeat,
|
||||
address_mode_v: ImageAddressMode::Repeat,
|
||||
mag_filter: ImageFilterMode::Linear,
|
||||
min_filter: ImageFilterMode::Linear,
|
||||
mipmap_filter: ImageFilterMode::Linear,
|
||||
anisotropy_clamp: 8,
|
||||
mipmap_filter: filter.mipmap_filter(),
|
||||
anisotropy_clamp: filter.anisotropy_clamp(),
|
||||
..default()
|
||||
});
|
||||
image
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! The frame's **texture filter policy** — one process-global pair, exactly as the reference holds
|
||||
//! it, and the divergence that pair was hiding.
|
||||
//!
|
||||
//! ## What the reference actually does
|
||||
//!
|
||||
//! The real 1.12.1 client does not let a texture choose how it is filtered. Every
|
||||
//! `TextureCreate` (`0x449d90`) runs the caller's request through **`0x449ae0`** with a force flag
|
||||
//! that is a literal `1` at every observed call site, and `0x449ae0` **overwrites** it from two
|
||||
//! globals: filter bits 0-2 from `[0x835250]`, aniso bits 9-13 from
|
||||
//! `(mode == 5 ? [0x835254] : 1)`. Their static `.data` values are **`[0x835250] = 3`** and
|
||||
//! **`[0x835254] = 1`**, and a full image-wide caller census of their only two writers
|
||||
//! (`0x449ac0` / `0x449ad0`) finds exactly three call sites, all inside the CVar callbacks below.
|
||||
//! WMO, M2/character and terrain textures all share the one policy; there is no per-draw filter
|
||||
//! override anywhere. (wow-re `system/models/scratch/wmo-texture-sampling.md` §2, VERIFIED off
|
||||
//! `0x449ae0`/`0x59f170`/`0x58a980`/`0x6c4c20` + the registration block `0x6885b0`-`0x688840`.)
|
||||
//!
|
||||
//! Mode 3 — the static `.data` value — is `GL_LINEAR_MIPMAP_NEAREST` / `GL_LINEAR`: bilinear with
|
||||
//! nearest-mip select, anisotropy off. Two CVars move it, and only upward:
|
||||
//!
|
||||
//! - **`trilinear`** (reg `0x688608`, registered default `"0"`) — nonzero sets mode 4,
|
||||
//! `GL_LINEAR_MIPMAP_LINEAR`.
|
||||
//! - **`anisotropic`** (reg `0x6887d0`, registered default **`"1"`**) — a value >1 sets mode 5
|
||||
//! (trilinear *plus* real aniso N, clamped to the device cap) and **outranks** `trilinear`.
|
||||
//!
|
||||
//! ## …and why the registrar's string is not what the client renders at
|
||||
//!
|
||||
//! **`hwDetect` overwrites it before the first frame.** `hwDetect` registers `"1"`, and
|
||||
//! `DetectHardware 0x641260` → `0x639a60` then `CVar::Set`s **sixteen** video CVars from the
|
||||
//! matched `VideoHardware.dbc` row, self-clears to `"0"`, and persists — so the registrar's string
|
||||
//! is only ever what an install renders at if the hardware table has nothing to say.
|
||||
//!
|
||||
//! It always has something to say. Every GPU this client runs on now is unlisted in a 2004 table,
|
||||
//! so the row comes from the **fallback** scan (`0x641610`, from record index 1, vendor `0xFFFF`
|
||||
//! against a D3D9 capability tier from `CGxDevice::DeviceAdapterInfer 0x58bc90`), whose reachable
|
||||
//! set is exactly three rows: **168** (tier 0), **169** (tier 1), **170** (tier 2). Tier 2 needs
|
||||
//! HW T&L, >2 simultaneous textures and PS ≥ 1.1 — which any D3D9 part saturates.
|
||||
//!
|
||||
//! **`trilinear` is 1 on rows 169 and 170, at both CPU tiers, and carries no CPU bias term.** So
|
||||
//! the mode a real machine gets from a virgin install is **4**, not 3, and that is what benilla
|
||||
//! registers (decision 1645, superseding 1642's `"0"`). Measured, not only derived: the reference
|
||||
//! client's own `WoW/Logs/gx.log` on the director's machine reads `VID: 106b` (unlisted) →
|
||||
//! `DeviceAdapterInfer … DID: 2` → `DetectHardware(): videoID: 170`.
|
||||
//!
|
||||
//! **`anisotropic` is NOT one of the sixteen** (verified by scanning `[0x639a60, 0x639b80)`:
|
||||
//! sixteen record-pointer reads, `0xc7f2e4` absent). It keeps its registered `"1"` on every path,
|
||||
//! so anisotropy really is off out of the box — 1642's load-bearing half stands.
|
||||
//!
|
||||
//! ## One correction to how wide the policy reaches
|
||||
//!
|
||||
//! 1642 said there is no per-texture filter override anywhere in the reference. That is **too
|
||||
//! broad**. `applyGlobal` is a per-*call* argument to `0x449ae0`, and while terrain (`0x6c4c77`),
|
||||
//! WMO (the same wrapper `0x6c4c20`) and M2 (`0x71da72`) all pass `1` — which is what this module
|
||||
//! needs and is confirmed — six subsystems pass `0` and keep their own mode: weather sprites
|
||||
//! (hard-coded mode 4), the minimap, glue/loading, Lua texture widgets and the sky. None of them
|
||||
//! is a lane this module builds a sampler for, so the code is right; the claim was not.
|
||||
//!
|
||||
//! ## The divergence this module closes
|
||||
//!
|
||||
//! benilla hardcoded `mipmap_filter: Linear` + `anisotropy_clamp: 8` at every sampler it built —
|
||||
//! mode 5 at aniso 8, on every terrain layer array, model albedo, WMO albedo and static-GX array
|
||||
//! in the game, with no CVar and no way to turn it off. That is the *maximum* of what the
|
||||
//! reference will do on request, shipped as the thing it does before anybody asks.
|
||||
//!
|
||||
//! It is [`decisions/1624`]'s mistake one sampler over, and it was made the same way. `farclip`
|
||||
//! shipped at 777 because that was "what most players' `Config.wtf` had"; the filtering shipped at
|
||||
//! trilinear+aniso because an apitrace of the repo's reference install showed trilinear+aniso16 —
|
||||
//! and that install's `Config.wtf` carries `SET anisotropic "16"`. Both read a *set* value as a
|
||||
//! *registered* one. Verifying that a mechanism exists is not verifying that it is the default.
|
||||
//!
|
||||
//! The cost is not incidental. Mode 3 takes one bilinear tap per sample. Mode 5 at aniso 8 takes up
|
||||
//! to **sixteen** — eight along the anisotropy axis, doubled by the trilinear mip blend — and the
|
||||
//! worst case for the anisotropy axis is a tiling surface at a grazing angle, which is what ground
|
||||
//! is from a third-person camera. On the GPU-bound Steam Deck frame behind 1624 (B329) the texture
|
||||
//! path is where the frame already lives.
|
||||
//!
|
||||
//! ## Why a process global here too
|
||||
//!
|
||||
//! Not imitation: the same constraint the reference had. A sampler is baked into the [`Image`]
|
||||
//! asset at load time, and the lanes that build one are an async `AssetLoader` with no world
|
||||
//! access ([`crate::blp`]) and ordinary systems ([`crate::world_assets`], `static_gx`) — neither
|
||||
//! can read the other's resource. This is exactly the shape [`crate::gpu_blp`]'s `bc_supported`
|
||||
//! already has, for exactly the same reason.
|
||||
//!
|
||||
//! **Latched, like `gxMultisample`** (decision 1629): read once at boot and never again, because a
|
||||
//! sampler already baked into an uploaded texture cannot be changed without rebuilding it — which
|
||||
//! is also why the reference's own UI says "enabled upon restart". The CVar holds the pending
|
||||
//! value; the textures keep what they were born with.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use bevy::image::ImageFilterMode;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::FilterMode;
|
||||
|
||||
/// The reference's `anisotropic` clamp — its callback parses 1-16 before clamping to the device
|
||||
/// cap (`0x689110`).
|
||||
pub const ANISO_RANGE: std::ops::RangeInclusive<u32> = 1..=16;
|
||||
|
||||
/// The two globals, as one value: `trilinear` and `anisotropic` resolved into the policy every
|
||||
/// sampler in the process takes.
|
||||
///
|
||||
/// The precedence is the reference's own, not a choice: `anisotropic >= 2` writes mode 5 at
|
||||
/// `0x68919f` and `trilinear` writes mode 4 at `0x688d0f` *only* when the aniso option bit is
|
||||
/// clear (`0x688d0d js` skips it), so aniso outranks trilinear whichever order the callbacks run.
|
||||
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct TexFilterSetting {
|
||||
/// `trilinear` — mode 4 when set and aniso is off.
|
||||
pub trilinear: bool,
|
||||
/// `anisotropic` — 1 is off; >=2 is mode 5 at that level.
|
||||
pub aniso: u32,
|
||||
}
|
||||
|
||||
impl Default for TexFilterSetting {
|
||||
/// What a virgin install actually renders at — `trilinear` **on**, `anisotropic` off — with
|
||||
/// `$WOW_TRILINEAR` / `$WOW_ANISO` overriding **session-only**, the same law `$WOW_MSAA` and
|
||||
/// `$WOW_FARCLIP` run under: this is the A/B lever for pricing the filter policy on one machine
|
||||
/// in one session, and a value pinned into `config.toml` would make a measurement sticky.
|
||||
///
|
||||
/// `trilinear` is `true` here and not the registrar's `"0"` because `hwDetect` sets it on both
|
||||
/// fallback rows a real GPU can reach — see the module doc, and decision 1645.
|
||||
fn default() -> Self {
|
||||
let env_flag = |k: &str| {
|
||||
std::env::var(k)
|
||||
.ok()
|
||||
.map(|v| !matches!(v.as_str(), "" | "0" | "off"))
|
||||
};
|
||||
Self {
|
||||
trilinear: env_flag("WOW_TRILINEAR").unwrap_or(true),
|
||||
aniso: std::env::var("WOW_ANISO")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.map_or(1, |v| v.clamp(*ANISO_RANGE.start(), *ANISO_RANGE.end())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TexFilterSetting {
|
||||
/// The reference's filter mode 0-5 (`[0x835250]`) this policy resolves to. Only 3, 4 and 5 are
|
||||
/// reachable from the two CVars; the lower three are set by nothing in the shipped image.
|
||||
pub fn mode(self) -> u8 {
|
||||
if self.aniso >= 2 {
|
||||
5
|
||||
} else if self.trilinear {
|
||||
4
|
||||
} else {
|
||||
3
|
||||
}
|
||||
}
|
||||
|
||||
/// The mip filter this policy asks for: mode 3 selects the nearer mip, 4 and 5 blend the two.
|
||||
pub fn mipmap_filter(self) -> ImageFilterMode {
|
||||
match self.mode() {
|
||||
3 => ImageFilterMode::Nearest,
|
||||
_ => ImageFilterMode::Linear,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same, for the lanes that build a raw wgpu [`SamplerDescriptor`](
|
||||
/// bevy::render::render_resource::SamplerDescriptor) instead of an [`Image`]'s.
|
||||
pub fn gpu_mipmap_filter(self) -> FilterMode {
|
||||
match self.mode() {
|
||||
3 => FilterMode::Nearest,
|
||||
_ => FilterMode::Linear,
|
||||
}
|
||||
}
|
||||
|
||||
/// The max anisotropy — `(mode == 5 ? [0x835254] : 1)`, `0x449afc`.
|
||||
///
|
||||
/// wgpu additionally requires all three filters to be `Linear` before it will accept a clamp
|
||||
/// above 1, which mode 5 satisfies by construction and modes 3/4 never reach.
|
||||
pub fn anisotropy_clamp(self) -> u16 {
|
||||
if self.mode() == 5 {
|
||||
self.aniso.clamp(*ANISO_RANGE.start(), *ANISO_RANGE.end()) as u16
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static POLICY: OnceLock<TexFilterSetting> = OnceLock::new();
|
||||
|
||||
/// Publish the resolved policy for the process. Called once, from the CVar load
|
||||
/// (`benilla_app::cvars::load_config`, the `CvarLoad` set) — the earliest moment `config.toml` has
|
||||
/// been folded in, and before any system that can request a texture has run.
|
||||
pub fn publish_tex_filter(setting: TexFilterSetting) {
|
||||
let _ = POLICY.set(setting);
|
||||
}
|
||||
|
||||
/// The policy every sampler in the process takes.
|
||||
///
|
||||
/// Unpublished — the world viewer, a headless test, or a texture somehow requested ahead of
|
||||
/// `CvarLoad` — falls back to [`TexFilterSetting::default`], which is what a virgin install
|
||||
/// renders at. A race can therefore only ever produce the faithful value, never a wrong one.
|
||||
pub fn tex_filter() -> TexFilterSetting {
|
||||
*POLICY.get_or_init(TexFilterSetting::default)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// **What benilla actually ships** — `hwDetect`'s row-170 outcome, not the registrar's string:
|
||||
/// trilinear on, anisotropy off, mode 4 (decision 1645). If this ever reads mode 5, we are
|
||||
/// shipping the divergence 1642 removed; if it reads mode 3, we are back to reading the
|
||||
/// registrar's string as if `hwDetect` did not exist.
|
||||
///
|
||||
/// `Default` is the assertion's subject deliberately: it is the value every unpublished lane
|
||||
/// falls back to, so this pins the fallback and the shipped default in one place. It reads the
|
||||
/// env, hence the guard — the levers are session-only and a set one must not fail the suite.
|
||||
#[test]
|
||||
fn what_ships_is_trilinear_with_anisotropy_off() {
|
||||
if std::env::var_os("WOW_TRILINEAR").is_some() || std::env::var_os("WOW_ANISO").is_some() {
|
||||
return; // an A/B session owns the value; the levers are tested below on literals
|
||||
}
|
||||
let shipped = TexFilterSetting::default();
|
||||
assert!(
|
||||
shipped.trilinear,
|
||||
"hwDetect sets trilinear on rows 169 and 170"
|
||||
);
|
||||
assert_eq!(
|
||||
shipped.aniso, 1,
|
||||
"anisotropic is not one of hwDetect's sixteen"
|
||||
);
|
||||
assert_eq!(shipped.mode(), 4);
|
||||
assert_eq!(shipped.mipmap_filter(), ImageFilterMode::Linear);
|
||||
assert_eq!(shipped.anisotropy_clamp(), 1);
|
||||
}
|
||||
|
||||
/// The registrar's own string, which is a real thing that is simply not what a machine gets:
|
||||
/// `[0x835250] = 3` with `trilinear "0"` is the state a client would render at if the hardware
|
||||
/// table said nothing. Kept as a test because the mode-3 arm is still reachable — any run whose
|
||||
/// `config.toml` or `$WOW_TRILINEAR` says 0 takes it.
|
||||
#[test]
|
||||
fn the_registrars_own_string_is_still_mode_three() {
|
||||
let registrar = TexFilterSetting {
|
||||
trilinear: false,
|
||||
aniso: 1,
|
||||
};
|
||||
assert_eq!(registrar.mode(), 3);
|
||||
assert_eq!(registrar.mipmap_filter(), ImageFilterMode::Nearest);
|
||||
assert_eq!(registrar.gpu_mipmap_filter(), FilterMode::Nearest);
|
||||
assert_eq!(registrar.anisotropy_clamp(), 1);
|
||||
}
|
||||
|
||||
/// `trilinear 1` alone is mode 4: the mip blend arrives, the aniso does not.
|
||||
#[test]
|
||||
fn trilinear_alone_is_mode_four() {
|
||||
let s = TexFilterSetting {
|
||||
trilinear: true,
|
||||
aniso: 1,
|
||||
};
|
||||
assert_eq!(s.mode(), 4);
|
||||
assert_eq!(s.mipmap_filter(), ImageFilterMode::Linear);
|
||||
assert_eq!(s.anisotropy_clamp(), 1);
|
||||
}
|
||||
|
||||
/// `anisotropic >= 2` is mode 5 and **outranks** `trilinear` in both directions — the mip
|
||||
/// blend comes with it whether or not `trilinear` was ever set.
|
||||
#[test]
|
||||
fn aniso_outranks_trilinear_in_both_directions() {
|
||||
for trilinear in [false, true] {
|
||||
let s = TexFilterSetting {
|
||||
trilinear,
|
||||
aniso: 8,
|
||||
};
|
||||
assert_eq!(s.mode(), 5, "aniso 8 is mode 5 with trilinear={trilinear}");
|
||||
assert_eq!(s.mipmap_filter(), ImageFilterMode::Linear);
|
||||
assert_eq!(s.anisotropy_clamp(), 8);
|
||||
}
|
||||
}
|
||||
|
||||
/// `anisotropic 1` is off, not "aniso at one sample" — the reference's own reading of the
|
||||
/// registered string, and the reason the default is not mode 5.
|
||||
#[test]
|
||||
fn aniso_one_is_off() {
|
||||
let s = TexFilterSetting {
|
||||
trilinear: false,
|
||||
aniso: 1,
|
||||
};
|
||||
assert_eq!(s.mode(), 3);
|
||||
}
|
||||
|
||||
/// wgpu rejects a clamp above 1 unless every filter is `Linear`; modes 3 and 4 must therefore
|
||||
/// never report one, whatever the CVar holds.
|
||||
#[test]
|
||||
fn a_clamp_above_one_only_ever_rides_all_linear_filters() {
|
||||
for s in [
|
||||
TexFilterSetting {
|
||||
trilinear: false,
|
||||
aniso: 1,
|
||||
},
|
||||
TexFilterSetting {
|
||||
trilinear: true,
|
||||
aniso: 1,
|
||||
},
|
||||
TexFilterSetting {
|
||||
trilinear: true,
|
||||
aniso: 16,
|
||||
},
|
||||
] {
|
||||
if s.anisotropy_clamp() > 1 {
|
||||
assert_eq!(s.mipmap_filter(), ImageFilterMode::Linear);
|
||||
assert_eq!(s.gpu_mipmap_filter(), FilterMode::Linear);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,14 +74,18 @@ pub fn repeat_texture_authored(upload: crate::gpu_blp::UploadChain, wrap: (bool,
|
||||
ImageAddressMode::ClampToEdge
|
||||
}
|
||||
};
|
||||
// The reference forces the mip filter and the anisotropy from two process globals at every
|
||||
// `TextureCreate`, and a virgin install lands on trilinear with anisotropy OFF
|
||||
// ([`crate::tex_filter`]). This read `Linear` / `8` — mode 5 — under the comment "the vanilla
|
||||
// look", which was the look of an install with `SET anisotropic "16"` in its `Config.wtf`.
|
||||
let filter = crate::tex_filter::tex_filter();
|
||||
image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
|
||||
address_mode_u: mode(wrap.0),
|
||||
address_mode_v: mode(wrap.1),
|
||||
// Trilinear + anisotropic: smooth, sharp-where-it-should-be, no shimmer — the vanilla look.
|
||||
mag_filter: ImageFilterMode::Linear,
|
||||
min_filter: ImageFilterMode::Linear,
|
||||
mipmap_filter: ImageFilterMode::Linear,
|
||||
anisotropy_clamp: 8,
|
||||
mipmap_filter: filter.mipmap_filter(),
|
||||
anisotropy_clamp: filter.anisotropy_clamp(),
|
||||
..default()
|
||||
});
|
||||
image
|
||||
@@ -143,16 +147,22 @@ pub fn liquid_frame_array(frames: Vec<BlpMipChain>) -> Image {
|
||||
dimension: Some(TextureViewDimension::D2Array),
|
||||
..default()
|
||||
});
|
||||
// **The apitrace that pinned this was read one step too far.** WoW.20 showed the ripple
|
||||
// sampler at LINEAR_MIPMAP_LINEAR + MAX_ANISOTROPY 16 and it was recorded here as "VERIFIED
|
||||
// the reference ripple sampler" — but that capture came off the repo's reference install,
|
||||
// whose `Config.wtf` carries `SET anisotropic "16"`. It proves the *mechanism* (the global
|
||||
// reaches the liquid sampler like every other), not the *default*, which is aniso off. There
|
||||
// is no per-texture filter override anywhere in the reference, so water takes the same policy
|
||||
// as ground ([`crate::tex_filter`]) — and at `anisotropic 16` this lane is byte-identical to
|
||||
// what it was.
|
||||
let filter = crate::tex_filter::tex_filter();
|
||||
image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
|
||||
address_mode_u: ImageAddressMode::Repeat,
|
||||
address_mode_v: ImageAddressMode::Repeat,
|
||||
// Trilinear + 16× anisotropic — VERIFIED the reference ripple sampler (apitrace WoW.20: tex 442,
|
||||
// LINEAR_MIPMAP_LINEAR + MAX_ANISOTROPY 16). This is what turns the unmipped sparkle into the
|
||||
// reference's sparse, smooth, elongated wave-streaks at the grazing water surface.
|
||||
mag_filter: ImageFilterMode::Linear,
|
||||
min_filter: ImageFilterMode::Linear,
|
||||
mipmap_filter: ImageFilterMode::Linear,
|
||||
anisotropy_clamp: 16,
|
||||
mipmap_filter: filter.mipmap_filter(),
|
||||
anisotropy_clamp: filter.anisotropy_clamp(),
|
||||
..default()
|
||||
});
|
||||
image
|
||||
|
||||
@@ -228,21 +228,25 @@ fn init_pipelines(mut commands: Commands, render_device: Res<RenderDevice>) {
|
||||
),
|
||||
);
|
||||
// TWO samplers per array — repeat and clamp, both matching the BLP loader's model albedo
|
||||
// sampler exactly (linear tri-filtered, ANISOTROPY 8 — `blp.rs`; the aniso is load-bearing
|
||||
// for parity, oblique minification reads visibly softer without it). The shader selects by
|
||||
// the vertex word's wrap bits: a shared array cannot carry per-layer address modes. The
|
||||
// rare MIXED-wrap batch (repeat one axis, clamp the other) keeps the repeat sampler plus
|
||||
// the shader's half-texel inset clamp on its clamped axis — an approximation confined to
|
||||
// that class (decision 0763's silhouette concern, honoured per axis).
|
||||
// sampler exactly. Parity with `blp.rs` was always the rule here; what changed is that
|
||||
// `blp.rs` no longer picks the numbers. The mip filter and the anisotropy are the process
|
||||
// filter policy's (`benilla_assets::tex_filter`), which the reference forces onto every
|
||||
// texture it creates from `[0x835250]`/`[0x835254]` — so this lane stays in step by asking
|
||||
// the same question rather than by copying the same literals. The shader selects by the
|
||||
// vertex word's wrap bits: a shared array cannot carry per-layer address modes. The rare
|
||||
// MIXED-wrap batch (repeat one axis, clamp the other) keeps the repeat sampler plus the
|
||||
// shader's half-texel inset clamp on its clamped axis — an approximation confined to that
|
||||
// class (decision 0763's silhouette concern, honoured per axis).
|
||||
let filter = benilla_assets::tex_filter();
|
||||
let make = |label: &'static str, mode: AddressMode| {
|
||||
render_device.create_sampler(&SamplerDescriptor {
|
||||
label: Some(label),
|
||||
min_filter: FilterMode::Linear,
|
||||
mag_filter: FilterMode::Linear,
|
||||
mipmap_filter: FilterMode::Linear,
|
||||
mipmap_filter: filter.gpu_mipmap_filter(),
|
||||
address_mode_u: mode,
|
||||
address_mode_v: mode,
|
||||
anisotropy_clamp: 8,
|
||||
anisotropy_clamp: filter.anisotropy_clamp(),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
|
||||
@@ -257,6 +257,31 @@ pub struct MsaaFormats {
|
||||
pub formats: Vec<(u32, u32, u32)>,
|
||||
}
|
||||
|
||||
impl MsaaFormats {
|
||||
/// `requested`, stepped DOWN to the nearest count this device actually offers.
|
||||
///
|
||||
/// **The single expression of "the device has the final say"** (decision 1643). It used to
|
||||
/// exist only inside [`MsaaSupportPlugin::finish`], which runs once, before the first update —
|
||||
/// i.e. against whatever [`MsaaSetting::default`] seeded, and therefore *before*
|
||||
/// `config.toml` had been folded in. The doc two paragraphs up already claimed that "the list
|
||||
/// a player picks from and the ceiling a typed value is clamped to cannot disagree"; a typed
|
||||
/// value was in fact never clamped to it at all, and a saved `gxMultisample = 8` opened on a
|
||||
/// GPU that stops at 4 handed the camera an 8 — the 2026-08-26 render-thread kill, one launch
|
||||
/// later and on a machine the setting was never chosen on.
|
||||
///
|
||||
/// **An empty list is no opinion, not a refusal.** Headless, or before the adapter exists,
|
||||
/// there is no device to be wrong about and `requested` passes through — the same early-out
|
||||
/// `finish` takes when there is no `RenderAdapter`. Clamping to 1 there would turn every
|
||||
/// device-less test and every worldview boot into a silent "multisampling off".
|
||||
pub fn clamp(&self, requested: u32) -> u32 {
|
||||
if self.formats.is_empty() {
|
||||
return requested;
|
||||
}
|
||||
let counts: Vec<u32> = self.formats.iter().map(|&(_, _, n)| n).collect();
|
||||
clamp_to_supported(requested, &counts)
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour and depth bit counts for the dropdown's label, derived from the formats we actually
|
||||
/// render into rather than typed: `MULTISAMPLING_FORMAT_STRING` is
|
||||
/// `"%d-bit color %d-bit depth %dx multisample"`, and a number in it should be true.
|
||||
@@ -312,7 +337,10 @@ impl Plugin for MsaaSupportPlugin {
|
||||
let Some(requested) = app.world().get_resource::<MsaaSetting>().map(|m| m.samples) else {
|
||||
return;
|
||||
};
|
||||
let granted = clamp_to_supported(requested, &supported);
|
||||
// Through `MsaaFormats::clamp`, not `clamp_to_supported` directly: this seed clamp and the
|
||||
// per-write clamp in `cvars::apply_to_knobs` must be the same rule, or the pair drifts
|
||||
// exactly the way the missing half already did (1643).
|
||||
let granted = app.world().resource::<MsaaFormats>().clamp(requested);
|
||||
if granted == requested {
|
||||
debug!("msaa: {requested}x accepted (this GPU offers {supported:?})");
|
||||
return;
|
||||
@@ -666,6 +694,32 @@ mod msaa_tests {
|
||||
assert_eq!(MsaaSetting { samples: 1 }.level(), Msaa::Off);
|
||||
}
|
||||
|
||||
/// [`MsaaFormats::clamp`] — the one expression of "the device has the final say" (1643),
|
||||
/// and both of its halves matter.
|
||||
///
|
||||
/// A device list steps a request DOWN to something the GPU will actually take, which is what
|
||||
/// stops a saved `gxMultisample` from reaching the camera as a wgpu validation error. An
|
||||
/// EMPTY list is no opinion rather than a refusal: headless, and before the adapter exists,
|
||||
/// there is no device to be wrong about — clamping to 1 there would turn every device-less
|
||||
/// run into a silent "multisampling off", including the one this crate's own tests run in.
|
||||
#[test]
|
||||
fn the_device_list_steps_down_and_an_empty_one_has_no_opinion() {
|
||||
let apple = MsaaFormats {
|
||||
formats: vec![(32, 32, 1), (32, 32, 2), (32, 32, 4)],
|
||||
};
|
||||
assert_eq!(apple.clamp(4), 4, "a count it offers is untouched");
|
||||
assert_eq!(apple.clamp(3), 2, "down to the nearest offered, never up");
|
||||
assert_eq!(apple.clamp(8), 4, "the 2026-08-26 render-thread kill");
|
||||
assert_eq!(apple.clamp(16), 4);
|
||||
assert_eq!(apple.clamp(1), 1);
|
||||
|
||||
let headless = MsaaFormats::default();
|
||||
assert!(headless.formats.is_empty());
|
||||
for n in [1, 2, 4, 8, 16] {
|
||||
assert_eq!(headless.clamp(n), n, "no adapter, no opinion");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sample_count_rounds_down_never_up() {
|
||||
let level = |n| MsaaSetting { samples: n }.level();
|
||||
|
||||
@@ -246,6 +246,10 @@ impl Plugin for WorldFoundation {
|
||||
// ONCE, at the camera's spawn, because the reference's own flag says latched.
|
||||
.init_resource::<crate::view::MsaaSetting>()
|
||||
.init_resource::<crate::view::MsaaFormats>()
|
||||
// The process texture filter policy (`trilinear` / `anisotropic`) — read ONCE, at the
|
||||
// CVar load, for the same reason: a sampler is baked into an uploaded texture and the
|
||||
// reference's own UI says "enabled upon restart".
|
||||
.init_resource::<benilla_assets::TexFilterSetting>()
|
||||
// ...and the clamp that keeps it inside what this GPU will actually accept. Runs in
|
||||
// plugin `finish()`, so it lands between the render adapter appearing and `Startup`
|
||||
// reading the setting — see `view::MsaaSupportPlugin`.
|
||||
|
||||
Reference in New Issue
Block a user