- right-clicking a vendor with an item on the cursor sells it: `0x5df5d0` asks `GetCursorItem` before anything else and a non-zero answer takes the arm to `CMSG_SELL_ITEM` instead of `CMSG_LIST_INVENTORY`, where benilla sent the list unconditionally — the whole stack, to the clicked vendor, with no merchant-window gate anywhere in the arm; the clear is `SetCursorItem(0, …)` rather than `ClearCursor`, which fires `CURSOR_UPDATE` and leaves the source slot greyed until the server's inventory update (1914); and the NPC you are talking to is not clickable: `0x5f0130` refuses a right-click on the NPC whose window is already open, against a global with exactly two writers — the interaction setter, whose fourteen callers are every one a window opener, and its clear — so the first click always gets through and no double-click is eaten, where benilla re-sent the arm's opcode on every click (1866) - a Hated or Hostile questgiver's `!` now disappears, every `!` blinks off and back on when you resurrect, and the minimap no longer dots a corpse, your own pet or an untrackable unit: the sweep tears down on HOSTILITY, not on the flag — `0x5eb134` is a convergent block reached from two branches, so three incompatible readings had each seen one path — and an escort giver's `!` clearing with its flag is the field watch instead, which B257 now rests on; the dots gain `0x4eaa90`'s three preconditions (1906); the quest log's rebuild is all-or-nothing — the reference makes every rebuild a no-op until its template misses are answered, so a cold log shows empty and then lands in one step, never our `"..."` row per in-flight quest — sorted level then title, case-insensitively, with the `id == 0` group first under the reference's own "Missing header! (quest designers)"; and `GetQuestLogTitle`'s `isHeader`/`isCollapsed` answer 1/nil, not booleans - an animation event has a place, not just a time: the M2 event kernel (`0x719370`) places each fired key through its bone, and benilla carried the when and the what and never the where, so every animation-event sound played at the model root — 149 of 244 `$DSL` records sit off their model's origin, out to 67.6 yd on `Maraudon_Waterfall01.m2` — and the point belongs to the record, not the tag, since 75 models author one tag twice; doodad, GameObject and shake sounds now sound where the key is (1904); and the unit dispatcher's arms do not agree about the point they are handed — footsteps and emotes at the event point (the footstep's material foley stays at the origin), `$TRD` at the origin, `$CSD` at attachment 17 rather than its head-mounted records, `$CSS` at the event point on a hit and attachment 1 on a miss — with `0x623b90`'s attachment-else-bias resolver transcribed table and all (1915) - `GetSkillLineInfo` out of range answers the reference's thirteen-value tuple, numbers in the rank slots, not a single nil — the stock skill window adds two of those slots unguarded straight off `SetSelectedSkill(0)`, so one nil raises the moment it drives us; one shape serves every out-of-range case because the bounds test at `0x4d3675` is a single unsigned compare, and our source had asserted the opposite (1919); the outbound wire is traced by name (`out`, mirroring `in`), a line pushed only after a successful write — a 5-second visit to the Goldshire poster reads as 87 lines (1901); the binding shape gate seats a player and checks the `Unit*` verbs it had been skipping, coverage 220 → 249, all agreeing with the reference (1911); and a UI capture scenario opts the UI in itself instead of photographing a UI-less world and exiting 0, the opt-in answering `false` in a player build that has no harness Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
395 lines
18 KiB
Rust
395 lines
18 KiB
Rust
//! **Footprint decals** — the prints a walking unit leaves on snow and sand (B212, decisions
|
||
//! 1006/1012): the fourth client of the shared surface-decal projector ([`benilla_world::decal`]), drawn
|
||
//! on the shared effect stream like the blob shadow, but **spawn-once**: a print is projected the
|
||
//! frame its foot plants and the cached triangles replay every frame until the fade retires it —
|
||
//! exactly the reference's own shape (baked once at spawn over collector-gathered ground
|
||
//! triangles; the per-frame draw `0x69a3e0` only re-copies with the current alpha).
|
||
//!
|
||
//! **The mechanism — wow-re §5 cross-checked** (`footprint-decals.md`, folded back in 1012):
|
||
//! - **Surface gate**: `TerrainType.Flags & 1` (`0x699eb6`) — set on exactly **Snow** and
|
||
//! **Sand** — on the surface [`benilla_world::surface`] resolves under the unit, the *same* value the
|
||
//! footstep sound uses. The reference resolves it once per unit into `CGUnit+0xc60` and the
|
||
//! decal, the spray and `$FSD` all read that one dword (wow-re `wmo-footstep-surface.md` F3), so
|
||
//! indoors the gate reads the building's own floor: a tavern's floorboards take no prints while
|
||
//! the snow outside its door does (decision 1161).
|
||
//! - **Trigger**: each per-foot animation event tag (`$xL*`/`$xR*`; the same [`AnimSoundEvent`]
|
||
//! stream the sounds read). **`$FSD` is sound-only** — a quadruped whose run authors only
|
||
//! `$FSD` leaves no prints, faithfully. Position = the event record's authored offset through
|
||
//! the live bone matrix ([`BoneAttach::markers`], `0x7196df`); yaw = the unit's facing.
|
||
//! - **Ink + size**: `CreatureModelData.FootprintTextureID` → `FootprintTextures.dbc` →
|
||
//! `textures\Footsteps\*` (32×32 pure-black-RGB under soft alpha), sized Length/Width ×(1/36)
|
||
//! inches→yards (`0x5fc310`/`0x607a00`) × the unit's wire `OBJECT_FIELD_SCALE_X` **only**
|
||
//! (`0x469f10` — no display scale multiplies in). `0xFFFFFFFF` = printless (133 of 430
|
||
//! models). A mounted composite prints the MOUNT's ink/dims (the mount model's own event
|
||
//! stream) at the RIDER's scale. **The texture is authored as the LEFT foot** — right-foot
|
||
//! prints mirror (`0x5fc07f`, scale(−1,1,1) before the yaw).
|
||
//! - **Fade**: lifetime **6000 ms**, `t = 1 − age/6000`, `alpha = min(127, ⌊255·t⌋)` — ≈3.0 s
|
||
//! hold at ~50 % opacity then a ≈3.0 s linear fade, no fade-in ([`fade`], byte-diffed
|
||
//! `PRIMITIVE:footprint_alpha` @`0x69a3e0`).
|
||
//! - **Caps**: ring pools of **64 local-player + 512 everyone-else** slots, unconditional
|
||
//! rotation (ring-select = GUID == local player, `0xca05f0`).
|
||
//! - **Suppressions**: hover (`MOVEFLAG 0x4000_0000`) · stealth (`BYTES_1` byte 3 bit 0x2 —
|
||
//! NOT death) · player ghost (`PLAYER_FLAGS & 0x10`) · farther than **50 yd** from the
|
||
//! **camera eye** ([`footfall_culls`] — the handler's own gate, shared with the footstep
|
||
//! camera shake, and applied to the local player's feet like anyone else's; decision 1856) ·
|
||
//! the terrain flag / printless id / no ground triangles. Water does NOT suppress
|
||
//! the decal (only the spray branch wades). The reference's `showfootprints` cvar (default on)
|
||
//! is the **decal's** toggle and only the decal's: `0x5fc023` skips the decal block alone, so
|
||
//! the footstep camera shake and the spray branch both still run with prints switched off. We
|
||
//! are always-on until a settings page wires the knob (the cvar-policy line, like the blob
|
||
//! shadow's `shadowLOD`).
|
||
//!
|
||
//! Draw state per the RE: src-alpha blend, depth-write off, unlit, white vertex RGB with the
|
||
//! fade in vertex alpha — the ink darkness lives in the texture. Still open there (none
|
||
//! load-bearing, note §13): the forward-axis sign convention inside the shared UV basis, the
|
||
//! uv1 64×8 edge-fade ramp's combine mode (not modeled here — prints are small; the blob
|
||
//! shadow's vertical trapezoid is that ramp's other consumer), and the frame order vs the blob
|
||
//! shadow (our rung 2048 under its 4096 is a deterministic stand-in).
|
||
|
||
use std::collections::VecDeque;
|
||
|
||
use bevy::platform::collections::HashMap;
|
||
use bevy::prelude::*;
|
||
|
||
use crate::creature_anim::{
|
||
footfall_culls, footfall_side, move_flags, AnimSoundEvent, MovementState,
|
||
};
|
||
use crate::entities::Creatures;
|
||
use crate::net::{Embodied, NetEntity, ObjectStore};
|
||
use crate::sound::footsteps::Footsteps;
|
||
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
|
||
use benilla_world::decal::{DecalFrame, WorldDecal};
|
||
use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
|
||
use benilla_world::schedule::WorldStage;
|
||
use benilla_world::view::WorldCamera;
|
||
|
||
/// Print lifetime, spawn to gone — the reference's 6000 ms (byte-verified, wow-re
|
||
/// `footprint-decals.md`: `t = 1 − age/6000`, die at `t < 0`).
|
||
const LIFETIME: f32 = 6.0;
|
||
/// The local player's own ring pool: 64 slots (the reserved head of the reference's 576-slot
|
||
/// table @`0xca05f0`), unconditional rotation.
|
||
const OWN_CAP: usize = 64;
|
||
/// Everyone else shares 512 slots (the table's tail), same rotation.
|
||
const SHARED_CAP: usize = 512;
|
||
/// Vertical reach of the projection slab about the planted foot: enough to catch the ground
|
||
/// through a slightly-lifted foot bone and drape a step edge, small enough not to paint a
|
||
/// terrace below (the blob shadow's slab is the model box; a print has no box to read).
|
||
const SLAB_HALF_HEIGHT: f32 = 1.0;
|
||
|
||
/// One live print: the projected world-space triangles (vertex alpha 1.0 — the fade multiplies
|
||
/// at push time), its spawn stamp, and its ink texture.
|
||
struct Print {
|
||
verts: Vec<EffectVertex>,
|
||
spawned: f32,
|
||
texture: AssetId<Image>,
|
||
/// The draw's sort anchor (the planted-foot spot).
|
||
anchor: Vec3,
|
||
}
|
||
|
||
/// The live prints — the reference's two ring pools ([`OWN_CAP`] local player /
|
||
/// [`SHARED_CAP`] everyone else), each spawn-ordered (front = oldest; spawn order is expiry
|
||
/// order — one shared lifetime). Resources, not per-print entities: prints are inert after
|
||
/// spawn — no identity, queries, or despawn wiring needed.
|
||
#[derive(Resource, Default)]
|
||
struct Footprints {
|
||
own: VecDeque<Print>,
|
||
shared: VecDeque<Print>,
|
||
}
|
||
|
||
/// The lane's one identity entity — the phase probe's `main_entity` for every print draw (prints
|
||
/// have no entity of their own; a probe line still needs a producer to name).
|
||
#[derive(Resource)]
|
||
struct FootprintLane(Entity);
|
||
|
||
/// `FootprintTextures.dbc` id → the loaded ink texture. Loaded once at startup (six rows).
|
||
#[derive(Resource, Default)]
|
||
struct FootprintInk(HashMap<u32, Handle<Image>>);
|
||
|
||
pub(crate) struct FootprintsPlugin;
|
||
|
||
impl Plugin for FootprintsPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
app.init_resource::<Footprints>()
|
||
.add_systems(Startup, load_ink.after(AssetSet::Open))
|
||
// Present, like the footstep sounds: the same event stream, after the frame's
|
||
// animation drive + transforms have settled the foot bones.
|
||
.add_systems(Update, spawn_footprints.in_set(WorldStage::Present))
|
||
.add_systems(PostUpdate, push_footprints.after(begin_effect_frame));
|
||
}
|
||
}
|
||
|
||
/// Read `FootprintTextures.dbc` and start the six ink textures loading.
|
||
fn load_ink(
|
||
mut commands: Commands,
|
||
assets: Option<Res<WorldAssets>>,
|
||
asset_server: Res<AssetServer>,
|
||
) {
|
||
let lane = commands.spawn(Name::new("footprint-lane")).id();
|
||
commands.insert_resource(FootprintLane(lane));
|
||
let Some(assets) = assets else { return };
|
||
let table = {
|
||
let mut chain = assets.chain.lock_recover();
|
||
benilla_formats::load_footprint_textures(&mut chain)
|
||
};
|
||
match table {
|
||
Ok(table) => {
|
||
let ink = table
|
||
.into_iter()
|
||
.map(|(id, path)| {
|
||
// DBC paths are extensionless (`textures\Footsteps\BaseFootprint`); the
|
||
// `mpq://` source wants forward slashes + the `.blp`.
|
||
let url = format!("mpq://{}.blp", path.replace('\\', "/"));
|
||
(id, asset_server.load::<Image>(url))
|
||
})
|
||
.collect();
|
||
commands.insert_resource(FootprintInk(ink));
|
||
}
|
||
Err(e) => warn!("footprints: FootprintTextures.dbc failed to load: {e:#}"),
|
||
}
|
||
}
|
||
|
||
/// The spawner's ROOT-unit reads (see [`spawn_footprints`]'s `roots` param).
|
||
type RootState = (
|
||
Has<Embodied>,
|
||
Option<&'static NetEntity>,
|
||
Option<&'static ObjectStore>,
|
||
Option<&'static MovementState>,
|
||
);
|
||
|
||
/// Spawn a print for each per-foot plant on a footprint surface: the state gates (hover /
|
||
/// stealth / ghost / distance), the terrain-flags gate, the unit's ink + params, the event
|
||
/// marker's live bone position, yaw to the facing, project once, cache the triangles.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn spawn_footprints(
|
||
mut events: MessageReader<AnimSoundEvent>,
|
||
time: Res<Time>,
|
||
// GlobalTransform for the same reason as the footstep sounds (0441): a mounted unit's steps
|
||
// are the MOUNT child's tags, whose local Transform is the seat-relative ~origin.
|
||
units: Query<(&NetEntity, &GlobalTransform)>,
|
||
// The spawner's ROOT (the rider for a mount child): the pool select, the print scale (the
|
||
// rider's SCALE_X — RE-corrected), and the state gates all read the root.
|
||
parents: Query<&ChildOf>,
|
||
roots: Query<RootState>,
|
||
camera: Query<&GlobalTransform, With<WorldCamera>>,
|
||
footsteps: Option<Res<Footsteps>>,
|
||
creatures: Option<Res<Creatures>>,
|
||
ink: Option<Res<FootprintInk>>,
|
||
world: benilla_world::world_point::WorldPoint,
|
||
decals: WorldDecal,
|
||
mut prints: ResMut<Footprints>,
|
||
) {
|
||
if events.is_empty() {
|
||
return;
|
||
}
|
||
let (Some(footsteps), Some(creatures), Some(ink)) = (footsteps, creatures, ink) else {
|
||
return;
|
||
};
|
||
// The distance gate's origin — the same read `camera_shake::fire_shakes` makes for the same
|
||
// gate. No world camera, no world to print into.
|
||
let Ok(eye) = camera.single().map(|t| t.translation()) else {
|
||
return;
|
||
};
|
||
let now = time.elapsed_secs();
|
||
for ev in events.read() {
|
||
// The VISUAL footfall channel: the per-foot side tags only (`$FSD` is the sound handler's,
|
||
// decision 1080). **Right** prints get the mirrored texture — the shipped art is the left
|
||
// foot (the `0x5fc07f` `side==0` mirror, corrected from 1006's guess in 1012).
|
||
let Some(side) = footfall_side(&ev.ident) else {
|
||
continue;
|
||
};
|
||
let Ok((net, transform)) = units.get(ev.entity) else {
|
||
continue;
|
||
};
|
||
// The ink + dims come from the EVENT's model (the mount for a mounted composite); the
|
||
// scale and every state gate from the ROOT unit (the rider) — the RE's split.
|
||
let Some(params) = net.display_id.and_then(|d| creatures.footprint(d)) else {
|
||
continue;
|
||
};
|
||
let Some(texture) = ink.0.get(¶ms.texture_id) else {
|
||
continue;
|
||
};
|
||
let mut root = ev.entity;
|
||
while let Ok(child_of) = parents.get(root) {
|
||
root = child_of.parent();
|
||
}
|
||
let (is_self, root_net, store, movement) = match roots.get(root) {
|
||
Ok(r) => r,
|
||
Err(_) => continue,
|
||
};
|
||
// The state gates (each byte-verified, module docs): hover · stealth · player ghost.
|
||
if movement.is_some_and(|m| m.flags & move_flags::HOVER != 0) {
|
||
continue;
|
||
}
|
||
if let Some(store) = store {
|
||
if store.0.unit_is_stealthed() || store.0.player_is_ghost() {
|
||
continue;
|
||
}
|
||
}
|
||
// The planted foot: the FIRED key's own point, resolved once by the scanner as the kernel
|
||
// snapshots it (decision 1904). It used to be a by-4CC re-find of the marker table, which
|
||
// is the *launch-point* mechanism (`0x7130e0`, first match) and not this one — a model that
|
||
// authors a side tag twice would have printed both feet at the first record's bone.
|
||
let foot = ev.pos.unwrap_or_else(|| transform.translation());
|
||
// The handler's own 50 yd radius about the CAMERA EYE — every unit's feet, the
|
||
// player's included ([`footfall_culls`]).
|
||
if footfall_culls(eye, foot) {
|
||
continue;
|
||
}
|
||
// The surface gate, on the SAME terrain type the footstep sound used: the reference
|
||
// resolves it once per unit (`CGUnit+0xc60`) and the decal, the spray and `$FSD` all read
|
||
// that one dword (wow-re `wmo-footstep-surface.md` F3). So this reads the UNIT's surface,
|
||
// not a per-foot sample of the ground — indoors that is the building's own floor, which is
|
||
// why a tavern's floorboards take no prints while the snow outside its door does.
|
||
let terrain = world.terrain_type(
|
||
&footsteps.0,
|
||
benilla_world::world_point::Subject::Unit(ev.entity),
|
||
transform.translation(),
|
||
);
|
||
if !terrain.is_some_and(|t| footsteps.0.terrain_leaves_footprints(t)) {
|
||
continue;
|
||
}
|
||
// Print frame: length along the unit's facing, width across, yawed to the facing.
|
||
// With `(sin, cos) = (sin θ, cos θ)` the facing maps to the frame's −z′ axis, so v = 0
|
||
// is the toe end (the absolute sign convention is the RE's open §13 item — a
|
||
// backwards-pointing print flips one sign here).
|
||
let yaw = transform
|
||
.to_scale_rotation_translation()
|
||
.1
|
||
.to_euler(EulerRot::YXZ)
|
||
.0;
|
||
// The rider's wire SCALE_X for a mounted composite, the unit's own otherwise (RE: the
|
||
// print scale is SCALE_X alone — display scales never multiply in).
|
||
let scale = root_net.unwrap_or(net).scale.max(0.0);
|
||
let (half_len, half_wid) = (params.length * scale * 0.5, params.width * scale * 0.5);
|
||
if half_len <= 0.0 || half_wid <= 0.0 {
|
||
continue;
|
||
}
|
||
let frame = DecalFrame {
|
||
center: foot,
|
||
sin: yaw.sin(),
|
||
cos: yaw.cos(),
|
||
min_x: -half_wid,
|
||
max_x: half_wid,
|
||
min_z: -half_len,
|
||
max_z: half_len,
|
||
min_y: -SLAB_HALF_HEIGHT,
|
||
max_y: SLAB_HALF_HEIGHT,
|
||
};
|
||
let mirror = side == b'R';
|
||
let mut verts = Vec::new();
|
||
let projected = decals.project(
|
||
&mut verts,
|
||
&frame,
|
||
|_| 1.0,
|
||
|x, z| {
|
||
let [u, v] = frame.rect_uv(x, z);
|
||
[if mirror { 1.0 - u } else { u }, v]
|
||
},
|
||
);
|
||
if !projected {
|
||
continue; // no receiving surface under the foot (the projector's no-ground gate)
|
||
}
|
||
let n_verts = verts.len();
|
||
let (pool, cap) = if is_self {
|
||
(&mut prints.own, OWN_CAP)
|
||
} else {
|
||
(&mut prints.shared, SHARED_CAP)
|
||
};
|
||
if pool.len() >= cap {
|
||
pool.pop_front(); // the reference's unconditional ring rotation
|
||
}
|
||
pool.push_back(Print {
|
||
verts,
|
||
spawned: now,
|
||
texture: texture.id(),
|
||
anchor: foot,
|
||
});
|
||
// The lane's census line (`RUST_LOG=benilla_app::footprints=debug`): every spawn names
|
||
// its tag, ink, spot and triangle count — the first question of any "no prints under X"
|
||
// report, answerable from a log (the blob shadow census pattern).
|
||
debug!(
|
||
"footprint: {} ink {} at ({:.2}, {:.2}, {:.2}), {} verts ({} own + {} shared live)",
|
||
ev.ident.map(char::from).iter().collect::<String>(),
|
||
params.texture_id,
|
||
foot.x,
|
||
foot.y,
|
||
foot.z,
|
||
n_verts,
|
||
prints.own.len(),
|
||
prints.shared.len(),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The reference's fade at age seconds (byte-diffed `PRIMITIVE:footprint_alpha` @`0x69a3e0`):
|
||
/// `t = 1 − age/6 s`, `alpha_byte = min(127, ⌊255·t⌋)` — ≈3.0 s hold at 127/255 (~50 %
|
||
/// opacity), then linear to 0; no fade-in; dead past [`LIFETIME`].
|
||
fn fade(age: f32) -> f32 {
|
||
let t = 1.0 - age / LIFETIME;
|
||
if t <= 0.0 {
|
||
return 0.0;
|
||
}
|
||
(255.0 * t).floor().min(127.0) / 255.0
|
||
}
|
||
|
||
/// Retire expired prints, then replay every live print's cached triangles onto the effect
|
||
/// stream — src-alpha blend of the black-RGB ink, the fade multiplied into the vertex alpha at
|
||
/// push time (the cache stays untouched).
|
||
fn push_footprints(
|
||
time: Res<Time>,
|
||
cam: Query<Entity, With<WorldCamera>>,
|
||
lane: Option<Res<FootprintLane>>,
|
||
mut draw: benilla_world::particles::buffer::WorldEffectDraw,
|
||
mut prints: ResMut<Footprints>,
|
||
) {
|
||
let now = time.elapsed_secs();
|
||
let prints = &mut *prints;
|
||
for pool in [&mut prints.own, &mut prints.shared] {
|
||
while pool.front().is_some_and(|p| now - p.spawned >= LIFETIME) {
|
||
pool.pop_front();
|
||
}
|
||
}
|
||
if prints.own.is_empty() && prints.shared.is_empty() {
|
||
return;
|
||
}
|
||
let Ok(cam) = cam.single() else { return };
|
||
let Some(lane) = lane else { return };
|
||
for print in prints.own.iter().chain(prints.shared.iter()) {
|
||
let alpha = fade(now - print.spawned);
|
||
let mut batch = draw
|
||
.batch(cam, print.texture)
|
||
.anchored(print.anchor)
|
||
.rung(
|
||
benilla_world::sky_order::Rung::FOOTPRINT,
|
||
benilla_world::sky_order::Rung::DECAL_RASTER,
|
||
)
|
||
.owner(lane.0);
|
||
batch.extend(print.verts.iter().map(|v| EffectVertex {
|
||
color: [v.color[0], v.color[1], v.color[2], v.color[3] * alpha],
|
||
..*v
|
||
}));
|
||
batch.tris();
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// The byte-verified ramp: no fade-in — 127/255 from age 0, held while `255·t ≥ 127`
|
||
/// (through age = 6·(1 − 127/255) ≈ 3.012 s), then the raw `⌊255·t⌋/255` line to zero at
|
||
/// 6 s, dead after.
|
||
#[test]
|
||
fn fade_matches_the_reference_ramp() {
|
||
assert_eq!(fade(0.0), 127.0 / 255.0);
|
||
assert_eq!(fade(1.0), 127.0 / 255.0);
|
||
assert_eq!(fade(3.0), 127.0 / 255.0);
|
||
// Past the hold knee: the raw line. age 4.5 → t = 0.25 → ⌊63.75⌋ = 63.
|
||
assert_eq!(fade(4.5), 63.0 / 255.0);
|
||
// age 5.988 → t = 0.002 → ⌊0.51⌋ = 0 — the line reaches the floor before death.
|
||
assert_eq!(fade(5.988), 0.0);
|
||
assert_eq!(fade(6.0), 0.0);
|
||
assert_eq!(fade(7.0), 0.0);
|
||
}
|
||
}
|