Files
moonwell-client-core/crates/benilla-ui/src/script/extract.rs
T
Sam Sung 1befd0404d The opening movie plays on first boot and from Cinematics (#2398)
The opening movie never played. `StartMovie` returned nil, so the stock `MovieFrame` hid at once.

1.12.1's movie frame (`0x78ce40`) reads the AVI, decodes, and streams the MP3. Its clock is the audio's play time, or the wall clock without audio. It shows clock × rate frames, decoding skipped ones unseen (`0x78cf30`). It reads `.sbt` captions (`0x78cb00`), and draws 2 or 6 tiles with the picture 24 or 6 rows up (`0x81d400`). On first boot the `movie` CVar flips to "0" and opens the movie screen (`0x402b3b`).

benilla now does all of that on the decoder crate, along with `StopMovie`, `EnableSubtitles`, `OnMovieFinished` (including on teardown), the captions' show and hide handlers, and screenshots waiting while a movie plays. Unattended runs skip the movie.

Tests cover each of these. Live, the logo and the intro played through to login.

Fixes #968

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:49:14 +07:00

495 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! [`UiScript::extract`], the render list: the visible-tree [`crate::order::traversal`] zipped
//! with the resolved rects and region visuals, in the client's painter order.
use crate::layout::Rect;
use crate::order::{self, ZTarget};
use crate::widget::FrameHandle;
use super::clip::DrawClips;
use super::{colorselect, layer_batch, slider, ExtractedQuad, QuadContent, TexCoords, UiScript};
impl UiScript {
/// Every live draw target, visible or not: each frame's slot then its regions, in arena order.
/// Handles are generational, so a diff across a load names exactly what the load created.
pub fn live_targets(&self) -> Vec<ZTarget> {
let model = self.model_ref();
let mut out = Vec::new();
for (fh, frame) in model.arena.iter_frames() {
out.push(ZTarget::Frame(fh));
out.extend(frame.regions.iter().copied().map(ZTarget::Region));
}
out
}
/// The frame a draw target belongs to: itself for a frame slot, the owner for a region.
pub fn target_frame(&self, target: ZTarget) -> Option<FrameHandle> {
match target {
ZTarget::Frame(fh) => self.model_ref().arena.frame(fh).map(|_| fh),
ZTarget::Region(rh) => self.model_ref().arena.region(rh).map(|r| r.owner),
}
}
/// A frame's parent, or `None` for a top-level frame or a stale handle.
pub fn frame_parent(&self, frame: FrameHandle) -> Option<FrameHandle> {
self.model_ref().arena.frame(frame)?.parent
}
/// A frame's name, or `None` for an anonymous frame or a stale handle.
pub fn frame_name(&self, frame: FrameHandle) -> Option<String> {
self.model_ref().arena.frame(frame)?.name.clone()
}
/// The name of the nearest named frame at or above `target`.
pub fn target_owner_name(&self, target: ZTarget) -> Option<String> {
let model = self.model_ref();
let mut frame = match target {
ZTarget::Frame(fh) => Some(fh),
ZTarget::Region(rh) => Some(model.arena.region(rh)?.owner),
};
while let Some(fh) = frame {
let f = model.arena.frame(fh)?;
if let Some(name) = &f.name {
return Some(name.clone());
}
frame = f.parent;
}
None
}
/// The render list in painter order, ascending by `ZKey`; call [`UiScript::resolve`] first.
pub fn extract(&self) -> Vec<ExtractedQuad> {
// The files set since the last draw take their stamps: this draw forces them.
self.model_mut().texels.flush();
let model = self.model_ref();
let list = order::traversal(&model.arena);
let mut out = Vec::with_capacity(list.len());
// A scroll child's subtree clips to its ScrollFrames' rects, nested ones intersecting.
let mut clips = DrawClips::new(&model, &list);
// Every texture quad's batch and texture, for the batches' End sort below.
let mut batch_quads: Vec<layer_batch::BatchQuad> = Vec::new();
for &order::DrawEntry {
target,
key: zkey,
scope,
} in list.iter()
{
if zkey.parts().strata == order::Strata::World.index()
&& super::nameplate::awaits_batch(&model, target)
{
continue;
}
// Under a ScrollFrame with no rect the callback draws nothing (`0x787253`).
let Some(clip) = clips.clip(scope) else {
continue;
};
// A region's texture, which ranks it in the batch sort.
let mut region_tex = None;
let (rect, alpha, content, clip, scale) = match target {
ZTarget::Frame(fh) => {
let frame = model.arena.frame(fh);
let alpha = frame.map_or(1.0, |f| crate::color::alpha_f32(f.alpha));
let scale = frame.map(|f| f.effective_scale).unwrap_or(1.0);
let content = match frame.map(|f| &f.kind_state) {
// The zoom cells are the minimap's statics, one pair for every Minimap.
Some(crate::widget::KindState::Minimap(_)) => QuadContent::Minimap {
zoom: model.minimap.zoom,
inside_zoom: model.minimap.inside_zoom,
},
// A `<Model>`/`<PlayerModel>` pane's content hole. Both kinds share
// `KindState::Model`, as the client's `CGCharacterModelBase` extends
// `CSimpleModel`.
Some(crate::widget::KindState::Model(m)) => QuadContent::ModelPane {
handle: fh,
name: frame.and_then(|f| f.name.clone()),
character: m.character.is_some(),
model: m.path.clone(),
facing: m.facing,
model_scale: m.scale,
position: m.position,
own_alpha: frame.map_or(1.0, |f| crate::color::alpha_f32(f.alpha)),
icon: m.icon.clone(),
camera: m.camera.clone(),
light: m.light,
fog: m.scene_fog(),
ffx: m.ffx,
},
// A playing movie's picture, once it has shown one (`0x78d68b`).
_ if model.movie_drawn(fh) => QuadContent::Movie { handle: fh },
_ => QuadContent::Frame,
};
(model.frame_rect(fh), alpha, content, clip, scale)
}
ZTarget::Region(rh) => {
let region = model.arena.region(rh);
let owner = region.map(|r| r.owner);
let owner_frame = owner.and_then(|o| model.arena.frame(o));
// A layer switched off by `Frame:DisableDrawLayer` hides its regions; skipped
// here so each region keeps its own shown state for when the layer returns.
if let (Some(r), Some(f)) = (region, owner_frame) {
if f.disabled_layers & (1 << r.draw_layer.index()) != 0 {
continue;
}
}
let mut rect = owner.and_then(|o| model.frame_rect(o));
// A StatusBar's bar texture fills the bar (`0x7831bf` pins it to every point);
// its coordinates cut it to the fill (`0x7833c0`, `modified_rect` below).
let mut bar_fill = false;
if let Some(crate::widget::KindState::StatusBar(sb)) =
owner_frame.map(|f| &f.kind_state)
{
bar_fill = sb.bar == Some(rh);
}
// A Slider's thumb likewise: at the value fraction along the track.
let mut thumb_fill = false;
if let Some(crate::widget::KindState::Slider(sl)) =
owner_frame.map(|f| &f.kind_state)
{
if sl.thumb == Some(rh) {
// The thumb's own `CSimpleTexture::GetWidth`/`GetHeight` at the
// slider's scale, as the drag reads them (`slider::thumb_extent`), not
// its authored `<Size>`.
let tsize = super::region::screen_span(&model, rh);
rect = rect
.map(|r| slider::thumb_rect(r, tsize, sl.vertical, sl.fraction()));
thumb_fill = true;
}
}
// The colour picker: the wheel and strip resolve like any region and the app
// paints them; the markers' rects derive from the HSV, inverting the pick.
let mut color_art: Option<QuadContent> = None;
let mut color_thumb = false;
if let Some(crate::widget::KindState::ColorSelect(cs)) =
owner_frame.map(|f| &f.kind_state)
{
// The markers' authored size, in the picker's units.
let scale = owner_frame.map_or(1.0, |f| f.effective_scale);
let tsize = model
.region_data
.get(&rh)
.and_then(|d| d.size_script(model.layout_units()))
.map(|(w, h)| (w * scale, h * scale));
if cs.wheel_thumb == Some(rh) {
rect = cs
.wheel
.and_then(|w| model.region_rect(w))
.map(|w| colorselect::wheel_thumb_rect(w, tsize, cs.hsv));
color_thumb = true;
} else if cs.value_thumb == Some(rh) {
// The strip anchors it and the wheel scales it: the client's unguarded
// `[this+0x318]` read.
let wheel = cs.wheel.and_then(|w| model.region_rect(w));
rect = cs
.value_strip
.and_then(|v| model.region_rect(v))
.map(|v| colorselect::value_thumb_rect(v, wheel, tsize, cs.hsv));
color_thumb = true;
} else if cs.wheel == Some(rh) {
color_art = Some(QuadContent::ColorWheel);
} else if cs.value_strip == Some(rh) {
color_art = Some(QuadContent::ColorValue {
hue: cs.hsv[0],
sat: cs.hsv[1],
});
}
}
// A Button's state textures draw by their shown bit like any region. Its
// label paints from its own fields, which the state's embedded font pushed
// (`script::button`), so the draw and the measure read one font; only the
// pushed text offset is the button's.
let mut label_offset = [0.0f32; 2];
if let Some(crate::widget::KindState::Button(bs)) =
owner_frame.map(|f| &f.kind_state)
{
let hovered = owner.is_some() && model.mouseover == owner;
// The press is not read here: the state texture latches on the transition
// (`ButtonState::set_state`). Only the Highlight, unlatched, reads hover.
if !bs.highlight_draws(rh, hovered) {
continue;
}
if bs.text == Some(rh) {
label_offset = bs.label_offset(model.layout_units());
}
}
// A title region never draws: `CreateTitleRegion 0x773910` builds a plain
// Region with no textures, a hit rectangle only.
if region.map(|r| r.kind) == Some(crate::widget::RegionKind::Title) {
continue;
}
// Hidden (the VisibleRegion bit): no quad; tested before the clone below.
let data_ref = model.region_data.get(&rh);
if data_ref.is_some_and(|d| d.hidden) {
continue;
}
// Deviation: the nameplate glow is shown but never drawn, because its additive
// rim reads as hard edge lines in our linear-blending pipeline; the lit plate
// brightens its bar instead. It stays a real shown ADD region, as
// `glow:IsShown()` is the mouseover signal 1.12 nameplate addons read.
if data_ref.is_some_and(super::nameplate::is_unpainted_glow) {
continue;
}
let mut data = data_ref.cloned().unwrap_or_default();
region_tex = data.tex.id();
// One hop (region combine `0x772180`): the region's alpha times its owner's
// byte, which `SetAlpha 0x76a690` already carries down the tree.
let alpha = owner_frame.map_or(1.0, |f| crate::color::alpha_f32(f.alpha))
* data.alpha.map_or(1.0, crate::color::alpha_f32);
// A region draws at its resolved rect: drawable regions carry anchors
// (authored, or `region::implicit_creation_anchor`), so a region that never
// resolved draws nowhere, as in the reference. After a failed resolve
// (`0x768d55`) a FontString stops (`0x7730b7` needs the valid bit) and a
// Texture keeps the quad its last good resolve wrote (`0x7706e0`). The bar
// fill and thumbs keep their fraction geometry.
let is_text = matches!(
region.map(|r| r.kind),
Some(crate::widget::RegionKind::FontString)
);
// A FontString with no font builds no string (`0x7724f8`) and emits nothing
// (`0x772e5b`), whatever its text.
if is_text
&& data.font_path.is_none()
&& !super::font_block::has_font(&model, rh)
{
continue;
}
if !bar_fill && !thumb_fill && !color_thumb {
rect = model
.region_rect(rh)
.or_else(|| (!is_text).then(|| model.region_kept_rect(rh)).flatten());
}
if !is_text && data.tex_coord_modifies_rect {
rect = rect.map(|r| modified_rect(r, data.tex_coords));
}
// A pushed label's text moves by its offset, `x · scale + left`,
// `y · scale + bottom` (`0x772100`); its rect, anchors and edges stay.
if label_offset != [0.0, 0.0] {
let k =
model.layout_unit() * owner_frame.map_or(1.0, |f| f.effective_scale);
let (dx, dy) = (label_offset[0] * k, label_offset[1] * k);
rect = rect.map(|r| {
crate::layout::Rect::new(
r.bottom + dy,
r.left + dx,
r.top + dy,
r.right + dx,
)
});
}
// Generated art fills only an empty slot (`ColorPickerFrame.xml` sets none).
let content = if let Some(art) =
color_art.filter(|_| data.tex.path().is_none() && data.tex.fill().is_none())
{
art
} else if is_text {
let spacing = data.line_gap();
let text_height = data.text_height(model.layout_units());
let font_height = data.font_height_script(model.layout_units());
let text_color = data.vertex_color_or_white();
QuadContent::Text {
text: data.text,
// The slot's colour as `0x77f8c0` answers it: an empty one, or one
// set to white that stored nothing (`0x77f766`), draws white.
color: Some(text_color.to_f32()),
// The gx translator's answer, not the getter's: a cleared axis draws
// CENTER/MIDDLE (`0x44d420`), where `GetJustifyH` says "UNKNOWN".
justify_h: data.justify.paint_h(),
justify_v: data.justify.paint_v(),
font: data.font_path,
font_height,
text_height,
shadow: data.font_shadow.map(|s| s.drawn(model.layout_units())),
outline: data.outline,
monochrome: data.monochrome,
spacing,
alpha_gradient: data.alpha_gradient,
// The owner's property: an addon's strings on a plate slide with it.
world_seat: owner
.is_some_and(|o| super::nameplate::is_world_seated(&model, o)),
edit_line: matches!(
owner_frame.map(|f| &f.kind_state),
Some(crate::widget::KindState::EditBox(eb))
if eb.text_region == Some(rh) && !eb.multi_line
),
non_space_wrap: model.non_space_wrap_of(rh),
max_lines: data.max_lines,
// A block of the SimpleHTML's last `SetText` (`0x78b011`).
link_owner: owner.filter(|o| {
model
.simple_html
.get(o)
.is_some_and(|st| st.blocks.contains(&rh))
}),
}
} else {
// The draw gate is the texture slot, never the colour (`0x7706e0`: empty
// `+0xcc` emits nothing): a vertex colour or a gradient alone is a tint.
let fill = data.tex.fill();
let has_path = data.tex.path().is_some();
let has_texture = has_path || fill.is_some();
let wrap = data.tex.wrap();
QuadContent::Texture {
path: std::mem::take(&mut data.tex).into_path(),
color: has_texture
.then(|| {
let mut tint =
data.vertex_color.map(crate::color::Rgba8::to_f32);
if let (Some(t), Some(k)) = (
tint.as_mut(),
super::nameplate::lit_fill_boost(&model, rh),
) {
t[..3].iter_mut().for_each(|c| *c *= k);
}
texture_color(fill.map(crate::color::Rgba8::to_f32), tint)
})
.flatten(),
// The quad record carries the four colours with stride 4
// (`0x77304d`), one per vertex, each times the texel.
gradient: data
.gradient
.filter(|_| has_texture)
.map(|g| g.vertex_colors()),
blend: drawn_blend(
data.blend,
owner_frame.map_or(1.0, |f| crate::color::alpha_f32(f.alpha)),
data.alpha.map_or(1.0, crate::color::alpha_f32),
data.vertex_color.map(crate::color::Rgba8::to_f32),
data.gradient.map(|g| g.vertex_colors()),
),
tex_coords: data.tex_coords,
wrap,
circular: data.circular,
portrait_unit: data.portrait_unit,
rotation: data.rotation,
// Real art only: a solid here is a tint on a white texel, where the
// reference's is a texel block it would grey. No stock caller
// desaturates a solid.
desaturated: data.desaturated && has_path,
}
};
// A region draws at its owner's scale: only frames carry one (`0x76ac90`).
let scale = owner_frame.map(|f| f.effective_scale).unwrap_or(1.0);
(rect, alpha, content, clip, scale)
}
};
// A `Model` frame's scene draws in its bucket's ARTWORK batch, last: `0x76fb00` drains
// quads, text, then render callbacks, and `0x76d160` registers the model's callback
// for layer 2 only (`0x76d17f cmp ebx,2`). So the world map's player arrow draws over
// the zone overlays at the same level, below its own OVERLAY and HIGHLIGHT regions.
// A movie's picture likewise: `0x78d650` registers its callback for layer 2 alone.
let z = match &content {
QuadContent::ModelPane { .. } | QuadContent::Movie { .. } => {
zkey.callback(order::DrawLayer::Artwork).raw()
}
_ => zkey.raw(),
};
// A slot's texture by its stamp; the colour picker's two images, built when it loads
// (`0x78b580`, `0x78b8a0`), before anything a script sets.
let rank = match &content {
QuadContent::Texture { .. } => region_tex.map(|id| model.texels.stamp(id)),
QuadContent::ColorWheel | QuadContent::ColorValue { .. } => Some(0),
_ => None,
};
if let (Some(rank), Some(batch)) = (rank, order::quad_batch(z)) {
batch_quads.push(layer_batch::BatchQuad {
index: out.len(),
batch,
rank,
});
}
out.push(ExtractedQuad {
target,
z,
rect,
alpha,
content,
clip,
scale,
});
// A ScrollingMessageFrame adds one Text quad per visible ring line, stacked bottom-up
// with each line's fade alpha; the ring lives in its kind state, not in FontStrings.
if let (ZTarget::Frame(fh), Some(fr)) = (target, rect) {
// The lines take the ARTWORK content key, where the client's message strings
// live, over the BACKGROUND (hover box) art.
let paint = super::layout::FramePaint { alpha, scale };
Self::emit_message_lines(
&model,
fh,
fr,
zkey.content(order::DrawLayer::Artwork).raw(),
paint,
clip,
&mut out,
);
}
}
// Each layer batch's End sorts its quads by texture (`0x7731a0`).
layer_batch::sort_batches(&mut out, &batch_quads);
out
}
}
/// The blend a texture's record draws with (`0x76fb00`): a record with a colour array and a mode
/// below `BLEND` draws as `BLEND` (`0x76fb85`-`0x76fba2`). The array is there exactly when it holds
/// an entry (`0x773035`), and the frame-alpha fold (`0x77fac0`) leaves one exactly when the frame's
/// alpha byte is below `0xfe` (`0x77fb6a` builds a white one) or an entry is not opaque white
/// (`0x77faf5`-`0x77fb23`). The entries are the vertex colour, whose alpha byte the region's
/// `SetAlpha` writes (`0x79b580`), or a gradient's four; a solid fill is the texel (`0x770360`).
fn drawn_blend(
mode: crate::script::BlendMode,
frame_alpha: f32,
region_alpha: f32,
vertex: Option<[f32; 4]>,
gradient: Option<[[f32; 4]; 4]>,
) -> crate::script::BlendMode {
use crate::script::BlendMode;
if !matches!(mode, BlendMode::Disable | BlendMode::AlphaKey) {
return mode;
}
// A channel's byte as the colour setters pack it (`×255 + 0.5`, truncated, `0x40a2b0`).
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
let opaque_white =
|c: [f32; 4]| c[..3].iter().all(|&v| byte(v) == 0xff) && byte(c[3] * region_alpha) >= 0xfe;
let all_white = match (gradient, vertex) {
(Some(g), _) => g.into_iter().all(opaque_white),
(None, Some(c)) => opaque_white(c),
(None, None) => opaque_white([1.0; 4]),
};
if byte(frame_alpha) < 0xfe || !all_white {
BlendMode::Blend
} else {
mode
}
}
/// The colour a Texture region draws with: texel × vertex colour per channel, alpha included (the
/// stage-0 combine preset at submit, `.data 0x85c250` index 1: `MODULATE(TEXTURE, DIFFUSE)`). A
/// desaturated region's pixel shader (`+0x128`) reads only the vertex alpha. `fill`, the region's
/// solid colour, is a real 8×8 texel block in the client, so where set the product is the drawn
/// colour; otherwise this is the tint the renderer modulates the art by, `None` for untinted.
fn texture_color(fill: Option<[f32; 4]>, vertex: Option<[f32; 4]>) -> Option<[f32; 4]> {
match (fill, vertex) {
(Some(f), Some(v)) => Some([f[0] * v[0], f[1] * v[1], f[2] * v[2], f[3] * v[3]]),
(Some(c), None) | (None, Some(c)) => Some(c),
(None, None) => None,
}
}
/// A texture's rect under `SetTexCoordModifiesRect` (`+0x124`), as `0x770570` derives it at every
/// layout (`0x7706ad`) and coordinate change (`0x770498`): from the bottom-left, the width times
/// `|URx − ULx|` and the height times `|LLy − ULy|`. A StatusBar's bar texture carries the flag
/// (`0x783220`), so its fill coordinates also cut its rect.
fn modified_rect(r: Rect, tex_coords: Option<TexCoords>) -> Rect {
let (across, down) = match tex_coords {
None => (1.0, 1.0),
Some(TexCoords::Rect([l, rt, t, b])) => ((rt - l).abs(), (b - t).abs()),
// Stored `[TL, TR, BR, BL]`: UL is TL, UR is TR, LL is BL.
Some(TexCoords::Corners(c)) => ((c[1][0] - c[0][0]).abs(), (c[3][1] - c[0][1]).abs()),
};
Rect::new(
r.bottom,
r.left,
r.bottom + r.height() * down,
r.left + r.width() * across,
)
}