diff --git a/crates/benilla/src/portrait/glue_booth.rs b/crates/benilla/src/portrait/glue_booth.rs index 6de49657..202ebf5a 100644 --- a/crates/benilla/src/portrait/glue_booth.rs +++ b/crates/benilla/src/portrait/glue_booth.rs @@ -761,8 +761,12 @@ pub(super) fn sync_glue_booth( booth_light: &mut BoothLight, materials: &mut Assets| { match (&scene_buf, scene.as_deref_mut()) { + // The glue screens keep the old fallback deliberately: the create/select scene has + // its own lifecycle (no map-scope teardown under it) and no parts-key retry to + // ride, so waiting here would leave the pane empty instead of merely mislit. (Some(buf), Some(s)) => { super::material_variant(&mut s.variants, buf, material, materials, true) + .unwrap_or_else(|| material.clone()) } _ => booth_light.studio.variant(material, materials), } diff --git a/crates/benilla/src/portrait/light.rs b/crates/benilla/src/portrait/light.rs index a776570b..30c8850e 100644 --- a/crates/benilla/src/portrait/light.rs +++ b/crates/benilla/src/portrait/light.rs @@ -24,20 +24,47 @@ use crate::terrain::WowModelMaterial; pub(super) struct BoothRig { pub(super) buffer: Option, variants: HashMap, Handle>, + /// Set by [`Self::variant`] when it could NOT build a real booth twin because the source + /// material was not resident yet — see [`Self::take_unready`]. + unready: bool, } impl BoothRig { /// The booth twin of a world-built material: same everything, this rig's light buffer. Cached /// per source material so twins dedup exactly like their sources. + /// + /// **A caller that is about to COMMIT a bake must consult [`Self::take_unready`] first.** When + /// the source material is not resident this returns the world material itself, which is bound + /// to the WORLD light buffer — and per this module's own header that is what "would render a + /// night portrait pitch black". Latching one into a booth is never right: the bake is stored + /// in a `MeshMaterial3d` and the booth then sleeps (0540), so a single unlucky frame freezes a + /// world-lit — or unlit — pane until something else forces a re-bake. pub(super) fn variant( &mut self, world: &Handle, materials: &mut Assets, ) -> Handle { let Some(buffer) = self.buffer.clone() else { - return world.clone(); // no booth buffer (headless tests) — fall back to the world light + // No booth buffer at all (headless tests) — there is nothing to wait FOR, so this + // stays a plain fallback rather than an unready signal. + return world.clone(); }; - material_variant(&mut self.variants, &buffer, world, materials, false) + match material_variant(&mut self.variants, &buffer, world, materials, false) { + Some(twin) => twin, + None => { + self.unready = true; + world.clone() + } + } + } + + /// Take the "a twin could not be built" flag accumulated since the last call. A bake site + /// checks this after collecting its parts and, if set, **abandons the bake for this frame** + /// without touching `Booth::baked` — the parts-key compare then re-fires next frame and the + /// bake retries once the material lands. This is the same law `Booth::pending` already applies + /// to not-yet-resident textures, moved one level up to the material itself. + pub(super) fn take_unready(&mut self) -> bool { + std::mem::take(&mut self.unready) } } @@ -85,19 +112,21 @@ pub(super) fn reap_dead_variants( /// stages none, its collector fog stays zeroed — wow-re `glue-model-lighting.md §5`; the /// background scene model, built by `sync_create_scene` with its own fog policy, keeps the /// `CharModelFogInfo` fog). +/// +/// **`None` means the source material is not resident yet** — there is no twin to hand back, and +/// the world material is NOT an acceptable substitute in a booth (wrong light buffer; see +/// [`BoothRig::variant`]). Callers wait and retry rather than baking something wrong. pub(super) fn material_variant( variants: &mut HashMap, Handle>, buffer: &bevy::render::render_resource::Buffer, world: &Handle, materials: &mut Assets, rig: bool, -) -> Handle { +) -> Option> { if let Some(twin) = variants.get(&world.id()) { - return twin.clone(); + return Some(twin.clone()); } - let Some(mat) = materials.get(world) else { - return world.clone(); - }; + let mat = materials.get(world)?; let mut twin = mat.clone(); twin.extension.light_buf = buffer.clone(); if rig { @@ -113,7 +142,7 @@ pub(super) fn material_variant( } let handle = materials.add(twin); variants.insert(world.id(), handle.clone()); - handle + Some(handle) } /// The **model pane's** light rows — the reference's own, for the `` panes (the diff --git a/crates/benilla/src/portrait/mod.rs b/crates/benilla/src/portrait/mod.rs index d776f7b3..30c1fe34 100644 --- a/crates/benilla/src/portrait/mod.rs +++ b/crates/benilla/src/portrait/mod.rs @@ -248,6 +248,57 @@ struct Booth { /// command-applied spawn, the billboard re-face's one-frame lag, and GPU upload of fresh meshes. const BOOTH_SETTLE_FRAMES: u32 = 4; +/// `WOW_BOOTH_LOG=1` — is the booth instrument armed? (Read once.) +fn booth_log() -> bool { + static LOG: std::sync::OnceLock = std::sync::OnceLock::new(); + *LOG.get_or_init(|| std::env::var_os("WOW_BOOTH_LOG").is_some()) +} + +/// `WOW_BOOTH_LOG=1` — one line per BAKE DECISION, which is the half the gate timeline cannot +/// show: the gate says a camera *drew*, never *what it drew*. B106's first-login shot is a +/// portrait wearing no armour beside a fully-equipped character, so the question is exactly "how +/// many parts/riders did the bake see, and did it ever re-bake when the rest arrived". +/// `verb`: `bake` committed · `wait` abandoned (a source material was not resident, 0744) · +/// `stand-in` the 2D fallback while no parts are attached · `empty` the booth was cleared. +fn log_bake(token: &str, verb: &str, parts: usize, riders: usize, billboards: usize) { + if booth_log() { + eprintln!("[booth] {token} {verb} parts={parts} riders={riders} billboards={billboards}"); + } +} + +/// The framing anchors for a bake — **`None` means "not ready, come back next frame", never +/// "this display has no anchors".** +/// +/// `Creatures::display_anchors` returns `None` in exactly two states, both transient: the display +/// is not in the cache yet, or its `parts` are *"not yet built"* (its own comment). Once built it +/// always answers `Some`; a display that genuinely has no authored portrait camera answers `Some` +/// with `camera: None` inside, which the framing heuristics are built to handle. +/// +/// Both bake sites used to paper over that with `unwrap_or(PortraitAnchors { .., pivot_height: 0.0, +/// .. })`. That is not a neutral default: [`framing::body_frame`] floors the head signal at `0.1` +/// for "a hypothetical bounds-less display", so zero anchors aim the camera at a 0.1-unit-tall +/// subject — the paper doll "zoomed into the max" and the wrong-size portrait of `#bugs` B106. And +/// it latches: the camera is aimed once per bake, and the parts key cannot change afterwards +/// (handle ids are stable from `load()`, so mesh/model arrival is invisible to it), so the frame +/// stays wrong until some unrelated content edge forces a re-bake. +/// +/// A display id of `None` is a different case — there is nothing to wait for — and keeps the +/// bounds-less anchors it always had. +fn booth_anchors( + creatures: Option<&Creatures>, + display_id: Option, +) -> Option { + let Some(display_id) = display_id else { + return Some(PortraitAnchors { + camera: None, + head: None, + pivot_height: 0.0, + ground_radius: 0.0, + }); + }; + creatures?.display_anchors(display_id) +} + /// Arm `booth` after a content edge: render the settle window, plus every frame until each twin /// material's texture is resident. `twins` = the material handles the bake just installed. fn wake_booth<'a>( @@ -673,6 +724,20 @@ fn sync_portraits( // The look changed — re-bake: studio-lit twins of the exact dressed materials, posed // at Stand on the booth's own throwaway skeleton (the ref bake — riders ride their // bone's joint, exactly like the world instance). + // Resolve the framing anchors FIRST — before anything is despawned or spawned. A + // `None` here means the display is still loading (see `booth_anchors`), and a bake + // committed now would aim the camera at fabricated zero bounds and never re-aim. + let Some(anchors) = booth_anchors(creatures.as_deref(), display_id) else { + booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES); + log_bake( + token, + "wait-anchors", + parts.len(), + riders.len(), + billboards.len(), + ); + continue; + }; let rig = creatures .as_deref() .zip(display_id) @@ -705,6 +770,15 @@ fn sync_portraits( kind: b.kind, }) .collect(); + // A source material was not resident, so at least one "twin" above is the WORLD + // material — bound to the world light buffer, which this booth's whole point is to + // avoid ("would render a night portrait pitch black", `light.rs`). Abandon the bake: + // `booth.baked` stays untouched, so the parts-key compare re-fires next frame and we + // retry once the material lands. The previous bake stays on screen meanwhile. + if booth_light.studio.take_unready() { + booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES); + continue; + } commands.entity(booth.root).despawn_related::(); spawn_booth_model( &mut commands, @@ -724,18 +798,9 @@ fn sync_portraits( &booth_billboards, ); // Frame through the display's authored portrait camera (heuristic anchors for the - // camera-less few; generic humanoid framing when the display cache has nothing). - let anchors = creatures - .as_deref() - .zip(display_id) - .and_then(|(c, d)| c.display_anchors(d)) - .unwrap_or(PortraitAnchors { - camera: None, - head: None, - pivot_height: 0.0, - ground_radius: 0.0, - }); + // camera-less few), resolved above before anything was torn down. aim(&mut cams, token, &frame(&anchors)); + log_bake(token, "bake", parts.len(), riders.len(), billboards.len()); wake_booth( booth, &wow_mats, @@ -907,6 +972,19 @@ fn sync_body_booth( let parts_changed = booth.baked.as_ref() != Some(&key); if parts_changed { let display_id = ent_q.get(unit).ok().and_then(|n| n.display_id); + // Anchors first, before any teardown — a still-loading display must not be framed from + // fabricated zero bounds (see the portrait site, and `booth_anchors`). + let Some(anchors) = booth_anchors(creatures, display_id) else { + booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES); + log_bake( + slot, + "wait-anchors", + parts.len(), + riders.len(), + billboards.len(), + ); + return; + }; let rig = creatures .zip(display_id) .and_then(|(c, d)| c.display_rig(d)); @@ -938,6 +1016,12 @@ fn sync_body_booth( kind: b.kind, }) .collect(); + // Same law as the portrait bake: never latch a world-lane material into the pane. Leave + // `booth.baked` alone and retry next frame (see the portrait site for the full note). + if booth_light.pane.take_unready() { + booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES); + return; + } commands.entity(booth.root).despawn_related::(); spawn_booth_model( commands, @@ -957,16 +1041,9 @@ fn sync_body_booth( &booth_billboards, ); // Body framing from the display's bounds — the full standing figure, feet-to-crown. - let anchors = creatures - .zip(display_id) - .and_then(|(c, d)| c.display_anchors(d)) - .unwrap_or(PortraitAnchors { - camera: None, - head: None, - pivot_height: 0.0, - ground_radius: 0.0, - }); + // Resolved before the teardown above; see the portrait site for why it cannot be faked. aim(cams, slot, &body_frame(&anchors)); + log_bake(slot, "bake", parts.len(), riders.len(), billboards.len()); wake_booth( booth, wow_mats, @@ -1002,6 +1079,7 @@ fn gate_booth_cameras( mut booths: ResMut, preview: Res, images: Res>, + time: Res>, mut cams: Query<(&BoothCam, &mut Camera)>, mut env_cache: Local>, ) { @@ -1025,7 +1103,8 @@ fn gate_booth_cameras( && (cam.is_active != active || active) { eprintln!( - "[booth] {} active={} wake={} pending={}", + "[booth] t={:7.2} {} active={} wake={} pending={}", + time.elapsed_secs(), token.as_str(), active, booth.wake, diff --git a/crates/benilla/src/portrait/test_bake.rs b/crates/benilla/src/portrait/test_bake.rs index 20abfe0b..6fa9954f 100644 --- a/crates/benilla/src/portrait/test_bake.rs +++ b/crates/benilla/src/portrait/test_bake.rs @@ -221,12 +221,32 @@ fn bake_test( /// named booth's render target (e.g. `paperdoll`) to `path`. A probe run can then look at the /// pane a live session would see under the character window — without a UI click path (the /// first-login black-pane hunt). One shot per run; inert without the env. +/// +/// **It must WAKE the booth before it shoots, and that is the whole subtlety.** Bevy's +/// `Screenshot::image` does not read the target texture's existing contents: it substitutes a +/// fresh `screenshot-capture-rendertarget` as that target's output attachment and hands back +/// whatever is *rendered into it during the capture frame* (`bevy_render`'s +/// `prepare_screenshots`). Under the 0540 demand gate a settled booth's camera is inactive and +/// its target simply "keeps the last render" — so shooting it while it sleeps renders nothing +/// into the substituted attachment and the PNG comes back a uniform `RGBA(0,0,0,0)`, which is +/// exactly the `Image::new_fill` pattern in `new_target_image` and exactly what this +/// instrument produced for its entire first life (every leg of the B106 hunt, control included, +/// byte-identical). So: arm `Booth::wake` first, hold it armed, and take the shot a few frames +/// later while the camera is still rendering. A dump that ever comes back uniformly transparent +/// again means the wake is not reaching the gate — treat it as a broken instrument, not a black +/// pane. pub(super) fn dump_booth_target( mut commands: Commands, - booths: Res, + mut booths: ResMut, time: Res>, - mut fired: Local, + mut phase: Local, ) { + /// Frames to hold the booth awake before taking the shot: the gate reads `wake` and flips + /// `Camera::is_active` in the same frame, so one would do — a small margin covers the + /// command-applied camera flip and the render-app extract behind it. + const WAKE_LEAD: u32 = 3; + const DONE: u32 = u32::MAX; + static SPEC: std::sync::OnceLock> = std::sync::OnceLock::new(); let Some((token, path, secs)) = SPEC.get_or_init(|| { let v = std::env::var("WOW_BOOTH_DUMP").ok()?; @@ -239,14 +259,24 @@ pub(super) fn dump_booth_target( }) else { return; }; - if *fired || time.elapsed_secs() < *secs { + if *phase == DONE || (*phase == 0 && time.elapsed_secs() < *secs) { return; } - *fired = true; - let Some(booth) = booths.0.get(token.as_str()) else { + let Some(booth) = booths.0.get_mut(token.as_str()) else { warn!("WOW_BOOTH_DUMP: no booth named {token:?}"); + *phase = DONE; return; }; + // Hold the gate open across the lead AND the capture frame itself. + booth.wake = booth.wake.max(super::BOOTH_SETTLE_FRAMES); + if *phase < WAKE_LEAD { + if *phase == 0 { + info!("WOW_BOOTH_DUMP: waking booth {token:?} for the shot"); + } + *phase += 1; + return; + } + *phase = DONE; use bevy::render::view::window::screenshot::{Screenshot, ScreenshotCaptured}; info!("WOW_BOOTH_DUMP: shooting booth {token:?} -> {path}"); let out = std::path::PathBuf::from(path.clone());