From 48c9c8444fe27a5db00d04031bea608334e93f0f Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 12 Jul 2026 20:04:29 -0500 Subject: [PATCH] Fire VISUALS wave: the authored firesmoke sheet, vertex-alpha effect cards, the case-4 wreck dressing The "fireballs like the demo vids" arc, decomp/content-grounded end to end, plus the live-play UX batch verified over the same sessions: FIRESMOKE SHEET (the PFX fireball fix): every firesmokeN_scr_tex in BTFX.VMF maps the SAME 64x64 tileable noise image bintA (variants differ only in SCROLL rate) and firesmoke1_mtl colours it through the "fiery" ramp (0.3,0.1,0.1)-> (0.9,0.7,0.3). The particle layer now bakes ramp(lum(bintA)) as its sprite colour (noise detail in alpha) and SCROLLS it at firesmoke1's authored rate via a texture-transform; the port's radial soft-edge mask moved to a second CLAMPed stage so the WRAPPED scroll rolls flame through the sprite without scrolling the edge away. Old grit x radial bake kept as the no-BINTA fallback. Impact hits, damage-band smoke and death booms all ride this layer. AUTHORED TEXTURE SCROLL in the model path: the BMF TEXTURE records carry SPECIAL " SCROLL u0 v0 du dv" (tag 0x2037); the draw path always supported per-op scrolling (SetTextureScrolling) but the BGF loader never parsed it, so every scrolling material rendered frozen. Wired TexRef -> MatInfo -> batch -> L4TEXOP.doScroll: the flame cards (flamebig/fire5) now roll fire noise. VERTEX-ALPHA EFFECT CARDS (the "twisted drill bit of fire" fix): FLAMEBIG's verts carry authored float RGBA -- white-hot base (1.0,0.99,0.97) -> dark-red tip fading to alpha -0.2 (the DPL clamp convention). The loader kept a flat batch colour and drew it OPAQUE = a solid orange spike. Corpus sweep: exactly 14 shipped BGFs carry vertex alpha, ALL effect cards (flames, MUZFLASH, EXDISK_A/B/C, TMST_A/B/C, beam models, DECLOUDS). Such batches now keep the authored per-vertex gradient and route to the alpha-blend pass, unlit, colour = texture x gradient, alpha = the vertex fade; sky objects excluded (drawAsSky + alphaTest passes NEITHER pass filter -- DECLOUDS stays in the sky pass). MUZFLASH/EXDISK render correctly for free when the muzzle-model work lands. WRECK DRESSING (the 1996 ExplosionScripts case-4 transcription): pieces spawn HIDDEN and reveal 0.25s after the boom (the InstanceSwitch delay, behind the dnboom flash); flamebig hangs over the pile, Y-BILLBOARDED at the camera (SetOffsetYaw + a camera-pos getter -- the dpl_SetDCSReorientAxes analog); the MakeDCSFall settle arms at the reveal with the two authored rates (hulk/ debris -0.025 t^2, fires -0.01 t^2 -- the flames ride above the sinking pile and die with it at burial). EMPTY-PLACEHOLDER hulk guard: THRDBR.BGF is a 153-byte zero-geometry stub that "loads fine" -- vertex-count check now routes it to the gendbr fallback (a Thor wreck was invisible). Hulk content census recorded: AVADBR==MADDBR==VULDBR geometry (palette-only prefix diffs), RAPDBR==SNDDBR==STIDBR byte-identical -- wreck variety is materials + the dressing, not unique piles. LIVE-PLAY BATCH: muzzle resolve uses the named segmentIndex (raw +0xdc read was layout garbage); forward launch frame (authored MuzzleVelocity +Z vs the mech's -Z facing); dock-bottom single window (gauge strip appended below the world viewport, 1100x600 default, BT_DEV_GAUGES_WINDOW=1 restores the separate window); portrait sec surface unrotated CW; ammo counters live via typed bridges (BTAmmoBinCountPtr/BTAmmoBinFeeding/BTWeaponAmmoBin -- raw bin+0x180 and a hand-rolled link walk were garbage); fourth fire key ('4' = Pinky); panel/arc probes de-aliased (%61 prime). KB: rendering.md (vertex-alpha card family + scroll), combat-damage.md (hulk census + THRDBR stub), gauges-hud.md (ammo bridges). Co-Authored-By: Claude Fable 5 --- context/combat-damage.md | 13 ++ context/gauges-hud.md | 11 ++ context/rendering.md | 12 ++ engine/MUNGA_L4/L4D3D.cpp | 64 +++++++- engine/MUNGA_L4/L4VB16.cpp | 74 ++++++++- engine/MUNGA_L4/L4VIDEO.cpp | 257 +++++++++++++++++++++++++------- engine/MUNGA_L4/L4VIDRND.h | 11 ++ engine/MUNGA_L4/bgfload.cpp | 70 ++++++++- engine/MUNGA_L4/bgfload.h | 13 ++ engine/MUNGA_L4/l4d3d.h | 6 + game/btl4main.cpp | 24 +++ game/reconstructed/ammobin.cpp | 15 ++ game/reconstructed/ammobin.hpp | 2 + game/reconstructed/btl4gau2.cpp | 22 ++- game/reconstructed/btl4vid.cpp | 107 ++++++++++++- game/reconstructed/btl4vid.hpp | 5 + game/reconstructed/mech4.cpp | 75 ++++++---- game/reconstructed/mechweap.cpp | 16 +- game/reconstructed/projweap.cpp | 12 ++ game/reconstructed/projweap.hpp | 1 + scratchpad/bintA.png | Bin 5050 -> 7164 bytes scratchpad/binta_fiery.png | Bin 0 -> 7587 bytes scratchpad/dbr_compare.png | Bin 0 -> 16307 bytes 23 files changed, 701 insertions(+), 109 deletions(-) create mode 100644 scratchpad/binta_fiery.png create mode 100644 scratchpad/dbr_compare.png diff --git a/context/combat-damage.md b/context/combat-damage.md index 82395b5..a1b5e8c 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -292,6 +292,19 @@ Full decomp map of the death path. Two tiers: `dbr.bgf` on the tree root (gendbr fallback; pending-swap if death precedes tree build). Mesh flames + the settle motion are noted follow-ups; burning reads via 1001/1007 + the wreck-smoke re-arm. Verified: `[BTrender] wreck swap: victim -> 'blhdbr.bgf'`. [T2] + **HULK CONTENT CENSUS (2026-07-12) [T1, byte-diffed]:** the shipped `*DBR.BGF` set shares + geometry heavily — `AVADBR`/`MADDBR`/`VULDBR` are byte-identical EXCEPT the material-library + prefix (`avafx:`/`madfx:`/`vulfx:` — same shape, per-mech palette); `RAPDBR`/`SNDDBR`/`STIDBR` + are fully byte-identical (same md5). Distinct shapes: BLH (1537v), LOK, OWN, GEN (659v), the + AVA/MAD/VUL shape (1351v), the RAP/SND/STI shape, `MDBR` (90KB), `LDBR` (46v scatter). All are + the same authored style (gray `genNmedgry`/`blakskn` + orange `dam3orng` slump piles), so two + different mechs' wrecks legitimately read alike; the original's visual differentiation came + from the case-4 DRESSING (flamesml/flamebig flames + chunk/fireball stagger + the MakeDCSFall + torso-height collapse), which is still a follow-up. ⚠ `THRDBR.BGF` is a 153-byte EMPTY + placeholder (one zero-geometry patch, `btfx:sparkred_mtl`) — it loads "successfully" as an + invisible object, so the gendbr fallback (which only fires on load FAILURE) never triggers + for a Thor: a Thor wreck would show only ldbr debris. [T1 content; T4 whether the pod binary + special-cased it] ⚠ **The death-effects DISPATCH lives at the VICTIM's death transition** (`UpdateDeathState`'s movementMode→9 moment, task #42) — NOT in any shooter's fire path. It originally sat in the laser fire block's kill check, which a MISSILE killing blow (landing frames later in diff --git a/context/gauges-hud.md b/context/gauges-hud.md index c7f6fff..879ef86 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -100,6 +100,17 @@ locked**. **RESOLVED 2026-07-12 [T2]:** deaths now tally per node from LOCALLY O own VehicleDead path counts its node), and `BTPilotDeaths` clamps the −2/−1 pre-acquire seed to 0 for display. Own row counts correctly as before. +## Launcher-panel recharge dial — authentically STATIC [T1, writer census] +The weapon panels' SegmentArc270 tick ring reads MechWeapon::rechargeLevel (+0x320). A FULL +writer census of the binary (all decomp spellings: `0x320) =`, `+ 800) =`, `[200] =`) finds +exactly four writers in the weapon family: the ctor (@004b99a8, 1.0), the stream init +(@004b8fec), ResetToInitialState (@004b96ec, 1.0), and the ONE live writer — +`Emitter::ComputeOutputVoltage` @004ba738, reached only through the Emitter vtable. Projectile +weapons (MissileLauncher/autocannon) never write it: their dial draws full ONCE and never moves +on the pod. The launcher panel's live indicators are the AMMO DIGITS (AmmoBin::ammoCount via +the complete-type bridges, 2026-07-12 — the old raw bin+0x180/0x18c reads were layout garbage), +the jam/fire lamps and the eject wipe. Do NOT "fix" the static ring — it is the authentic look. + ## ConfigMapGauge (the weapon panel's regroup lamp column) — authentically DORMANT The per-weapon btjoy.pcc joystick image + 4 cm_* state lamps (off/other/only/both) showing, for each mappable fire button (Pinky/ThumbLow/Trigger/ThumbHigh), whether THIS panel's weapon diff --git a/context/rendering.md b/context/rendering.md index 4126eca..0da8ea9 100644 --- a/context/rendering.md +++ b/context/rendering.md @@ -25,6 +25,18 @@ white blobs" bug (gate on `hasNormals`). [T2] Exception: cockpit-frame (`*_cop`) batches are pure-emissive constant colour (0.13,0.12,0.15), `BT_COP_FRAME` overrides — see [[cockpit-view]] (task #55; the old `BT_COP_RAMP_L` ramp-index approach is retired). [T2] +**VERTEX-ALPHA EFFECT CARDS (2026-07-12) [T1 corpus-swept]:** exactly 14 shipped BGFs carry +per-vertex float RGBA with alpha < 1 (tags 0x0082/0x008A, RGBA at +12, values OUTSIDE [0,1] — +clamp): FLAMEBIG/FIRE5 (flames), MUZFLASH, EXDISK_A/B/C, TMST_A/B/C, BLUEBEAM/GREEBEAM/RED_BEAM/ +PPC, DECLOUDS — the EFFECT-CARD family. Their authored gradient (white-hot base → dark tip fading +to alpha −0.2) IS the shading: drawn opaque with a flat batch colour they render as solid shapes +(the wreck flame read as a "twisted drill bit"). The loader now keeps the authored per-vertex +RGBA for any batch with vertex alpha < 1 (`BgfDrawBatch.vertexAlpha`) and L4D3D routes it to the +ALPHA-BLEND pass, unlit, colour = texture × gradient, alpha = vertex fade (SRCALPHA/INVSRCALPHA). +SKY objects are excluded from the routing (drawAsSky + alphaTest passes NEITHER pass filter — +DECLOUDS must stay in the sky pass). Authored TEXTURE **SCROLL** (BMF TEXTURE tag 0x2037 +`" SCROLL u0 v0 du dv"`, e.g. firesmoke1 = 0.05,−0.331 uv/s) is now parsed and propagated to +`L4TEXOP::doScroll` — scrolling materials (firesmoke flame cards) animate in the model path. [T2] ## The big render-fidelity fixes (dbase/desert matched to pod footage) - **Geometry:** CONN is a flat trilist (not fan); CONN+PCONN coexist — see [[bgf-format]]. Malformed diff --git a/engine/MUNGA_L4/L4D3D.cpp b/engine/MUNGA_L4/L4D3D.cpp index a75241a..60b129f 100644 --- a/engine/MUNGA_L4/L4D3D.cpp +++ b/engine/MUNGA_L4/L4D3D.cpp @@ -423,7 +423,14 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName) m.Diffuse.a = 1.0f; m.Ambient = m.Diffuse; // matches the .x path; lit by scene ambient even w/o direct lights - object->mDrawOps[i].alphaTest = object->mIsShadow != 0; // shadow -> blend pass + // shadow -> blend pass; vertex-alpha effect cards (flame fans) too. + // SKY objects are excluded: a drawAsSky op with alphaTest set passes + // NEITHER pass filter (sky pass rejects alphaTest, blend pass rejects + // drawAsSky) and vanishes -- DECLOUDS carries vertex alpha but must + // stay in the sky pass. + const bool vtxAlphaOp = data.batches[i].vertexAlpha && !isSkyObj; + object->mDrawOps[i].alphaTest = (object->mIsShadow != 0) || vtxAlphaOp; + object->mDrawOps[i].vertexAlphaBlend = vtxAlphaOp; object->mDrawOps[i].drawAsDecal = false; object->mDrawOps[i].drawAsSky = isSkyObj; // sky dome -> PASS_SKY (fullbright) // PUNCH (dpl_Punchize; BT_PUNCH=0 disables): cutout batch -- black texels @@ -435,7 +442,12 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName) object->mDrawOps[i].texture.texture = NULL; object->mDrawOps[i].texture.wrap_u = L4TEXOP::REPEAT; object->mDrawOps[i].texture.wrap_v = L4TEXOP::REPEAT; - object->mDrawOps[i].texture.doScroll = false; + // Authored TEXTURE SCROLL (BMF SPECIAL " SCROLL u0 v0 du dv"): the + // firesmoke family (flamebig/fire5 flame cards) rolls its fire noise. + // SetTextureScrolling in the draw path honours these per-op. + object->mDrawOps[i].texture.doScroll = data.batches[i].texScroll; + object->mDrawOps[i].texture.scrollUDelta = data.batches[i].texScrollU; + object->mDrawOps[i].texture.scrollVDelta = data.batches[i].texScrollV; const std::string &tp = data.batches[i].texPath; if (!tp.empty()) @@ -863,6 +875,15 @@ void d3d_OBJECT::SetCameraPosition(float x, float y, float z) gCamPos.x = x; gCamPos.y = y; gCamPos.z = z; } +// The camera's world position (set once per frame above) -- the wreck flame +// billboard (dpl_SetDCSReorientAxes analog) yaws its card toward this. +void d3d_OBJECT::GetCameraPosition(float *x, float *y, float *z) +{ + if (x) *x = gCamPos.x; + if (y) *y = gCamPos.y; + if (z) *z = gCamPos.z; +} + void d3d_OBJECT::SetCullFrustum(const D3DXMATRIX *viewProj) { if (viewProj == NULL) { gCullValid = 0; return; } @@ -1203,6 +1224,34 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target // 3. re-draw the mask writing stencil 0 (clean up for other punch groups) // The stencil never touches z, so entities drawn later still show through // the apertures correctly. + // VERTEX-ALPHA effect card (the FLAMEBIG flame fan): unlit -- the + // authored per-vertex gradient (white-hot base -> dark faded tip) IS + // the shading -- colour = texture x gradient, alpha = the vertex fade + // (SRCALPHA/INVSRCALPHA). Runs only in the blend pass (the op filter + // above routed it there; ALPHABLEND is on and z-write off pass-wide). + DWORD sVLight = 0, sVSrc = 0, sVDst = 0, sVCop = 0, sVCa1 = 0, sVCa2 = 0, sVAop = 0, sVAa2 = 0; + const bool vtxAlphaCard = (drawOp->vertexAlphaBlend && pass == PASS_ALPHABLEND); + if (vtxAlphaCard) + { + mDevice->GetRenderState(D3DRS_LIGHTING, &sVLight); + mDevice->GetRenderState(D3DRS_SRCBLEND, &sVSrc); + mDevice->GetRenderState(D3DRS_DESTBLEND, &sVDst); + mDevice->GetTextureStageState(0, D3DTSS_COLOROP, &sVCop); + mDevice->GetTextureStageState(0, D3DTSS_COLORARG1, &sVCa1); + mDevice->GetTextureStageState(0, D3DTSS_COLORARG2, &sVCa2); + mDevice->GetTextureStageState(0, D3DTSS_ALPHAOP, &sVAop); + mDevice->GetTextureStageState(0, D3DTSS_ALPHAARG2, &sVAa2); + mDevice->SetRenderState(D3DRS_LIGHTING, FALSE); + mDevice->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_SRCALPHA); + mDevice->SetRenderState(D3DRS_DESTBLEND, D3DBLEND_INVSRCALPHA); + mDevice->SetTextureStageState(0, D3DTSS_COLOROP, + drawOp->texture.texture != NULL ? D3DTOP_MODULATE : D3DTOP_SELECTARG2); + mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE); + mDevice->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_DIFFUSE); + mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG2); + mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_DIFFUSE); + } + const bool copCut = (drawOp->copRole == 2 && iOp > 0 && mDrawOps[iOp - 1].copRole == 1 && mDrawOps[iOp - 1].bgfPrimCount > 0 @@ -1266,6 +1315,17 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target mDevice->SetRenderState(D3DRS_ALPHAREF, sARef); mDevice->SetRenderState(D3DRS_ALPHAFUNC, sAFunc); } + if (vtxAlphaCard) + { + mDevice->SetRenderState(D3DRS_LIGHTING, sVLight); + mDevice->SetRenderState(D3DRS_SRCBLEND, sVSrc); + mDevice->SetRenderState(D3DRS_DESTBLEND, sVDst); + mDevice->SetTextureStageState(0, D3DTSS_COLOROP, sVCop); + mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, sVCa1); + mDevice->SetTextureStageState(0, D3DTSS_COLORARG2, sVCa2); + mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, sVAop); + mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, sVAa2); + } if (drawOp->lodDepthBias != 0.0f) mDevice->SetRenderState(D3DRS_DEPTHBIAS, sDB); } diff --git a/engine/MUNGA_L4/L4VB16.cpp b/engine/MUNGA_L4/L4VB16.cpp index 3ac60f4..ecec317 100644 --- a/engine/MUNGA_L4/L4VB16.cpp +++ b/engine/MUNGA_L4/L4VB16.cpp @@ -109,13 +109,48 @@ static bool DevGaugeComposite() return v != 0; } +// DOCK-BOTTOM (2026-07-12, single-window mode): the gauge strip is APPENDED to +// the main window's bottom band -- the world renders in the top region via a +// restricted viewport, the strip in the remainder, everything scales with the +// window. Enabled by btl4main when BT_DEV_GAUGES is set (BT_DEV_GAUGES_WINDOW=1 +// restores the separate-window mode). Strip height = width x (480/1320), the +// panel's design aspect. +int gBTGaugeDockBottom = 0; +static const float kBTDockStripRatio = 480.0f / 1320.0f; + +// The strip's height for a given window width: proportional to width at the +// panel's design aspect, CAPPED at the design height (480) -- a very wide +// window renders the strip at native 1:1 instead of upscaling it. +int BTGaugeStripHeightFor(int width) +{ + int h = (int)((float)width * kBTDockStripRatio); + return (h > 480) ? 480 : h; +} + +// Restrict rasterization to the WORLD region (target minus the bottom strip). +// Called by the main renderer right after BeginScene; no-op unless dock-bottom. +void BTApplyWorldViewport(LPDIRECT3DDEVICE9 device) +{ + if (!gBTGaugeDockBottom || device == NULL) return; + IDirect3DSurface9 *rt = NULL; + if (FAILED(device->GetRenderTarget(0, &rt)) || rt == NULL) return; + D3DSURFACE_DESC d; + rt->GetDesc(&d); + rt->Release(); + DWORD stripH = (DWORD)BTGaugeStripHeightFor((int)d.Width); + if (stripH == 0 || stripH >= d.Height) return; + D3DVIEWPORT9 vp = { 0, 0, d.Width, d.Height - stripH, 0.0f, 1.0f }; + device->SetViewport(&vp); +} + // DEV-COMPOSITE: BT_DEV_GAUGES_DOCK=1 docks the 6-surface panel INTO the main 800x600 -// window (occludes the 3D view); default (unset) presents them in a SEPARATE window. +// window (occludes the 3D view); dock-bottom (above) also counts as docked -- +// both suppress the separate gauge window and draw via the main-window inset. static bool DevGaugeDocked() { static int v = -1; if (v < 0) v = (getenv("BT_DEV_GAUGES_DOCK") != NULL) ? 1 : 0; - return v != 0; + return v != 0 || gBTGaugeDockBottom != 0; } //===========================================================================// @@ -285,6 +320,22 @@ void BTDrawGaugeSurfaces(LPDIRECT3DDEVICE9 device, float px, float py, float pw, IDirect3DStateBlock9 *stateBlock = NULL; device->CreateStateBlock(D3DSBT_ALL, &stateBlock); + // DOCK-BOTTOM: the world pass restricted the viewport to the top region; + // the strip's pretransformed quads land BELOW it and would be clipped. + // Draw with the FULL target viewport (the captured state block restores + // the world viewport afterwards). + { + IDirect3DSurface9 *rt = NULL; + if (SUCCEEDED(device->GetRenderTarget(0, &rt)) && rt != NULL) + { + D3DSURFACE_DESC d; + rt->GetDesc(&d); + rt->Release(); + D3DVIEWPORT9 vp = { 0, 0, d.Width, d.Height, 0.0f, 1.0f }; + device->SetViewport(&vp); + } + } + // Reach the shared display once (any present port shares the same SVGA16/pixelBuffer). SVGA16 *svga = NULL; for (int i = 0; i < 6 && svga == NULL; i++) @@ -335,8 +386,23 @@ void BTDrawGaugeSurfaces(LPDIRECT3DDEVICE9 device, float px, float py, float pw, void BTDrawGaugeInset(LPDIRECT3DDEVICE9 device) { if (!DevGaugeComposite() || !DevGaugeDocked() || device == NULL) return; - // DOCKED mode only: draw into the main backbuffer (this runs before EndScene). - // Keep the new panel's 1320:480 aspect (2.75) so nothing distorts. + if (gBTGaugeDockBottom) + { + // DOCK-BOTTOM: the strip owns the bottom band of the target (the world + // viewport excludes it) -- full width, design-aspect height. + IDirect3DSurface9 *rt = NULL; + if (FAILED(device->GetRenderTarget(0, &rt)) || rt == NULL) return; + D3DSURFACE_DESC d; + rt->GetDesc(&d); + rt->Release(); + float stripH = (float)BTGaugeStripHeightFor((int)d.Width); + if (stripH <= 0.0f || stripH >= (float)d.Height) return; + BTDrawGaugeSurfaces(device, 0.0f, (float)d.Height - stripH, + (float)d.Width, stripH); + return; + } + // Legacy overlay dock (BT_DEV_GAUGES_DOCK): fixed inset over the 3D view. + // Keep the panel's 1320:480 aspect (2.75) so nothing distorts. BTDrawGaugeSurfaces(device, 2.0f, 400.0f, 540.0f, 540.0f / 2.75f); } diff --git a/engine/MUNGA_L4/L4VIDEO.cpp b/engine/MUNGA_L4/L4VIDEO.cpp index e9f5361..ac47f58 100644 --- a/engine/MUNGA_L4/L4VIDEO.cpp +++ b/engine/MUNGA_L4/L4VIDEO.cpp @@ -20,6 +20,7 @@ #include "l4particles.h" #include "DXUtils.h" #include "bgfload.h" // BT: SetVideoPathPriority (day/night path priority) +#include "image.h" // BT: decodeImage (the authored firesmoke sheet bintA) using namespace std; #include @@ -936,6 +937,9 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt) dev->GetTextureStageState(0, D3DTSS_ALPHAOP, &sAop); dev->GetTextureStageState(0, D3DTSS_ALPHAARG1, &sAa1); dev->GetTextureStageState(0, D3DTSS_ALPHAARG2, &sAa2); + DWORD sAdrU, sAdrV; // the scroll WRAPs sampler 0 + dev->GetSamplerState(0, D3DSAMP_ADDRESSU, &sAdrU); + dev->GetSamplerState(0, D3DSAMP_ADDRESSV, &sAdrV); // PREMULTIPLIED blending (ONE, INVSRCALPHA): the one model that renders BOTH // authored particle families correctly. FIRE (bright colour, the additive @@ -959,43 +963,62 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt) dev->SetRenderState(D3DRS_DESTBLEND, s_pfxAdditive ? D3DBLEND_ONE : D3DBLEND_INVSRCALPHA); dev->SetRenderState(D3DRS_CULLMODE, D3DCULL_NONE); - // Particle sprite texture: the beams' authentic grit sheet MASKED by a - // radial falloff -> soft round fiery blobs (an unmasked square sheet on an - // additive quad reads as a hard BOX, which the 1995 sprites never did). - // Baked once from the decoded grit; pure radial gradient if grit is absent. - static LPDIRECT3DTEXTURE9 s_pfxTex = 0; + // Particle sprite texture -- the AUTHORED firesmoke sheet. Every + // firesmokeN_scr_tex in BTFX.VMF maps the SAME 64x64 tileable noise image + // "bintA" (the variants differ only in SCROLL rate: firesmoke1 = 0.05,-0.331 + // uv/s, firesmoke2 = 0,0.131, firesmoke4 = 0.05,-0.131), and firesmoke1_mtl + // colours it through the "fiery" ramp (0.3,0.1,0.1)->(0.9,0.7,0.3) -- the + // rolling orange fireball of the original. Bake ramp(lum) as the sprite + // colour with the noise detail in alpha; the RADIAL falloff lives in a + // SECOND (CLAMPed) texture stage so the WRAPPED scroll can roll flame + // through the sprite without scrolling the soft edge away. (The mask is + // port-shaped: we draw billboards where the original drew flame geometry, + // and a square scrolled sheet on a quad reads as a hard box.) + // Fallback when BINTA.VTX is absent: the old grit x radial single-tex bake. + static LPDIRECT3DTEXTURE9 s_pfxTex = 0; // fallback single-texture + static LPDIRECT3DTEXTURE9 s_pfxNoise = 0; // bintA through the "fiery" ramp + static LPDIRECT3DTEXTURE9 s_pfxMask = 0; // radial falloff (stage 1, CLAMP) static int s_pfxTexTried = 0; if (!s_pfxTexTried) { s_pfxTexTried = 1; const int TW = 64, TH = 64; - LPDIRECT3DTEXTURE9 grit = BTGetBeamGritTexture(); - unsigned char gritLum[128 * 64]; // grit is 128x64 when present - int gw = 0, gh = 0; - if (grit) - { - D3DSURFACE_DESC gd; - if (SUCCEEDED(grit->GetLevelDesc(0, &gd)) && gd.Width <= 128 && gd.Height <= 64) - { - D3DLOCKED_RECT glr; - if (SUCCEEDED(grit->LockRect(0, &glr, NULL, D3DLOCK_READONLY))) - { - gw = gd.Width; gh = gd.Height; - for (int y = 0; y < gh; ++y) - { - const DWORD *src = (const DWORD *)((const char *)glr.pBits + y * glr.Pitch); - for (int x = 0; x < gw; ++x) - gritLum[y * gw + x] = (unsigned char)(src[x] & 0xFF); - } - grit->UnlockRect(0); - } - } - } - if (SUCCEEDED(dev->CreateTexture(TW, TH, 1, 0, D3DFMT_A8R8G8B8, - D3DPOOL_MANAGED, &s_pfxTex, NULL))) + Image fireImg = decodeImage("VIDEO\\TEX\\BINTA.VTX"); + if (fireImg.ok && fireImg.w > 0 && fireImg.h > 0 && + SUCCEEDED(dev->CreateTexture(fireImg.w, fireImg.h, 1, 0, D3DFMT_A8R8G8B8, + D3DPOOL_MANAGED, &s_pfxNoise, NULL))) { D3DLOCKED_RECT lr; - if (SUCCEEDED(s_pfxTex->LockRect(0, &lr, NULL, 0))) + if (SUCCEEDED(s_pfxNoise->LockRect(0, &lr, NULL, 0))) + { + const float lo[3] = { 0.3f, 0.1f, 0.1f }; // RAMP "fiery" (BTFX.VMF) + const float hi[3] = { 0.9f, 0.7f, 0.3f }; + for (int y = 0; y < fireImg.h; ++y) + { + DWORD *dst = (DWORD *)((char *)lr.pBits + y * lr.Pitch); + for (int x = 0; x < fireImg.w; ++x) + { + uint32_t s = fireImg.argb[(size_t)y * fireImg.w + x]; + float lum = (((s >> 16) & 0xFF) + ((s >> 8) & 0xFF) + (s & 0xFF)) / (3.0f * 255.0f); + int r = (int)((lo[0] + (hi[0] - lo[0]) * lum) * 255.0f); + int g = (int)((lo[1] + (hi[1] - lo[1]) * lum) * 255.0f); + int b = (int)((lo[2] + (hi[2] - lo[2]) * lum) * 255.0f); + // alpha carries the noise detail (same 0.35 floor the grit + // bake used) -> the occlusion edge breaks up like flame + int a = (int)((0.35f + 0.65f * lum) * 255.0f); + dst[x] = ((DWORD)a << 24) | (r << 16) | (g << 8) | b; + } + } + s_pfxNoise->UnlockRect(0); + } + else { s_pfxNoise->Release(); s_pfxNoise = 0; } + } + if (s_pfxNoise && + SUCCEEDED(dev->CreateTexture(TW, TH, 1, 0, D3DFMT_A8R8G8B8, + D3DPOOL_MANAGED, &s_pfxMask, NULL))) + { + D3DLOCKED_RECT lr; + if (SUCCEEDED(s_pfxMask->LockRect(0, &lr, NULL, 0))) { for (int y = 0; y < TH; ++y) { @@ -1008,30 +1031,125 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt) float f = 1.0f - r; // radial falloff if (f < 0.0f) f = 0.0f; f = f * f * (3.0f - 2.0f * f); // smoothstep edge - float n = 1.0f; - if (gw > 0) // authentic grit detail - n = 0.35f + 0.65f * (gritLum[(y % gh) * gw + (x % gw)] / 255.0f); - int v = (int)(f * n * 255.0f); - if (v > 255) v = 255; - // the falloff mask lives in ALPHA too: the premultiplied - // draw (ONE, INVSRCALPHA) needs soft-edged OCCLUSION for - // smoke, not just soft-edged colour for fire - dst[x] = (v << 24) | (v << 16) | (v << 8) | v; + int v = (int)(f * 255.0f); + dst[x] = ((DWORD)v << 24) | (v << 16) | (v << 8) | v; } } - s_pfxTex->UnlockRect(0); + s_pfxMask->UnlockRect(0); + } + else { s_pfxMask->Release(); s_pfxMask = 0; } + } + if (s_pfxNoise && !s_pfxMask) { s_pfxNoise->Release(); s_pfxNoise = 0; } + if (!s_pfxNoise) + { + LPDIRECT3DTEXTURE9 grit = BTGetBeamGritTexture(); + unsigned char gritLum[128 * 64]; // grit is 128x64 when present + int gw = 0, gh = 0; + if (grit) + { + D3DSURFACE_DESC gd; + if (SUCCEEDED(grit->GetLevelDesc(0, &gd)) && gd.Width <= 128 && gd.Height <= 64) + { + D3DLOCKED_RECT glr; + if (SUCCEEDED(grit->LockRect(0, &glr, NULL, D3DLOCK_READONLY))) + { + gw = gd.Width; gh = gd.Height; + for (int y = 0; y < gh; ++y) + { + const DWORD *src = (const DWORD *)((const char *)glr.pBits + y * glr.Pitch); + for (int x = 0; x < gw; ++x) + gritLum[y * gw + x] = (unsigned char)(src[x] & 0xFF); + } + grit->UnlockRect(0); + } + } + } + if (SUCCEEDED(dev->CreateTexture(TW, TH, 1, 0, D3DFMT_A8R8G8B8, + D3DPOOL_MANAGED, &s_pfxTex, NULL))) + { + D3DLOCKED_RECT lr; + if (SUCCEEDED(s_pfxTex->LockRect(0, &lr, NULL, 0))) + { + for (int y = 0; y < TH; ++y) + { + DWORD *dst = (DWORD *)((char *)lr.pBits + y * lr.Pitch); + for (int x = 0; x < TW; ++x) + { + float dx = (x + 0.5f) / TW * 2.0f - 1.0f; + float dy = (y + 0.5f) / TH * 2.0f - 1.0f; + float r = sqrtf(dx * dx + dy * dy); + float f = 1.0f - r; // radial falloff + if (f < 0.0f) f = 0.0f; + f = f * f * (3.0f - 2.0f * f); // smoothstep edge + float n = 1.0f; + if (gw > 0) // authentic grit detail + n = 0.35f + 0.65f * (gritLum[(y % gh) * gw + (x % gw)] / 255.0f); + int v = (int)(f * n * 255.0f); + if (v > 255) v = 255; + // the falloff mask lives in ALPHA too: the premultiplied + // draw (ONE, INVSRCALPHA) needs soft-edged OCCLUSION for + // smoke, not just soft-edged colour for fire + dst[x] = (v << 24) | (v << 16) | (v << 8) | v; + } + } + s_pfxTex->UnlockRect(0); + } + else { s_pfxTex->Release(); s_pfxTex = 0; } } - else { s_pfxTex->Release(); s_pfxTex = 0; } } } - dev->SetTexture(0, s_pfxTex); - dev->SetTextureStageState(0, D3DTSS_COLOROP, s_pfxTex ? D3DTOP_MODULATE : D3DTOP_SELECTARG1); - dev->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_DIFFUSE); - dev->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_TEXTURE); - // occlusion = vertex alpha x the texture's falloff mask (soft-edged smoke) - dev->SetTextureStageState(0, D3DTSS_ALPHAOP, s_pfxTex ? D3DTOP_MODULATE : D3DTOP_SELECTARG1); - dev->SetTextureStageState(0, D3DTSS_ALPHAARG1, D3DTA_DIFFUSE); - dev->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_TEXTURE); + + // The authored SCROLL clock (firesmoke1's rate; the shipped variants share + // the sheet and differ only in rate, so the port rolls every sprite at the + // dominant one). Accumulate the OFFSETS and wrap each into [0,1) -- an + // unbounded time * rate loses float precision over a long session (the + // tsphere scroll-precision lesson). + static float s_pfxScrollU = 0.0f, s_pfxScrollV = 0.0f; + s_pfxScrollU += 0.05f * dt; s_pfxScrollU -= floorf(s_pfxScrollU); + s_pfxScrollV += -0.331f * dt; s_pfxScrollV -= floorf(s_pfxScrollV); + + if (s_pfxNoise) + { + // stage 0: diffuse x the scrolled fire noise (WRAPped) + dev->SetTexture(0, s_pfxNoise); + dev->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_MODULATE); + dev->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_DIFFUSE); + dev->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_TEXTURE); + dev->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_MODULATE); + dev->SetTextureStageState(0, D3DTSS_ALPHAARG1, D3DTA_DIFFUSE); + dev->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_TEXTURE); + D3DXMATRIX tm; D3DXMatrixIdentity(&tm); + tm._31 = s_pfxScrollU; tm._32 = s_pfxScrollV; // (u,v) translation + dev->SetTransform(D3DTS_TEXTURE0, &tm); + dev->SetTextureStageState(0, D3DTSS_TEXTURETRANSFORMFLAGS, D3DTTFF_COUNT2); + dev->SetSamplerState(0, D3DSAMP_ADDRESSU, D3DTADDRESS_WRAP); + dev->SetSamplerState(0, D3DSAMP_ADDRESSV, D3DTADDRESS_WRAP); + // stage 1: x the CLAMPed radial mask (same UV set, UNscrolled) -- both + // colour and alpha, so the premultiplied occlusion stays soft-edged + dev->SetTexture(1, s_pfxMask); + dev->SetTextureStageState(1, D3DTSS_COLOROP, D3DTOP_MODULATE); + dev->SetTextureStageState(1, D3DTSS_COLORARG1, D3DTA_CURRENT); + dev->SetTextureStageState(1, D3DTSS_COLORARG2, D3DTA_TEXTURE); + dev->SetTextureStageState(1, D3DTSS_ALPHAOP, D3DTOP_MODULATE); + dev->SetTextureStageState(1, D3DTSS_ALPHAARG1, D3DTA_CURRENT); + dev->SetTextureStageState(1, D3DTSS_ALPHAARG2, D3DTA_TEXTURE); + dev->SetTextureStageState(1, D3DTSS_TEXCOORDINDEX, 0); + dev->SetSamplerState(1, D3DSAMP_ADDRESSU, D3DTADDRESS_CLAMP); + dev->SetSamplerState(1, D3DSAMP_ADDRESSV, D3DTADDRESS_CLAMP); + dev->SetSamplerState(1, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR); + dev->SetSamplerState(1, D3DSAMP_MINFILTER, D3DTEXF_LINEAR); + } + else + { + dev->SetTexture(0, s_pfxTex); + dev->SetTextureStageState(0, D3DTSS_COLOROP, s_pfxTex ? D3DTOP_MODULATE : D3DTOP_SELECTARG1); + dev->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_DIFFUSE); + dev->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_TEXTURE); + // occlusion = vertex alpha x the texture's falloff mask (soft-edged smoke) + dev->SetTextureStageState(0, D3DTSS_ALPHAOP, s_pfxTex ? D3DTOP_MODULATE : D3DTOP_SELECTARG1); + dev->SetTextureStageState(0, D3DTSS_ALPHAARG1, D3DTA_DIFFUSE); + dev->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_TEXTURE); + } D3DXMATRIX ident; D3DXMatrixIdentity(&ident); dev->SetTransform(D3DTS_WORLD, &ident); @@ -1108,6 +1226,17 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt) &verts[0], sizeof(V)); dev->SetTexture(0, NULL); + if (s_pfxNoise) + { + // tear down the two-stage fire pipe: stage 1 back to the D3D default + // (DISABLE), the scroll transform off, sampler 0 addressing restored + dev->SetTexture(1, NULL); + dev->SetTextureStageState(1, D3DTSS_COLOROP, D3DTOP_DISABLE); + dev->SetTextureStageState(1, D3DTSS_ALPHAOP, D3DTOP_DISABLE); + dev->SetTextureStageState(0, D3DTSS_TEXTURETRANSFORMFLAGS, D3DTTFF_DISABLE); + } + dev->SetSamplerState(0, D3DSAMP_ADDRESSU, sAdrU); + dev->SetSamplerState(0, D3DSAMP_ADDRESSV, sAdrV); dev->SetRenderState(D3DRS_LIGHTING, sLight); dev->SetRenderState(D3DRS_FOGENABLE, sFog); dev->SetRenderState(D3DRS_ZWRITEENABLE, sZW); @@ -7445,6 +7574,14 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte hr = mDevice->BeginScene(); + // DOCK-BOTTOM (single-window gauges): keep the world in the top region; + // the gauge strip owns the bottom band (drawn by BTDrawGaugeInset with a + // full-target viewport before EndScene). No-op unless the mode is on. + { + extern void BTApplyWorldViewport(LPDIRECT3DDEVICE9 device); + BTApplyWorldViewport(mDevice); + } + mDevice->SetFVF(L4VERTEX_FVF); D3DXMATRIX viewTransform; @@ -8558,11 +8695,29 @@ void // backbuffer into the client area, so rendering with the CLIENT aspect cancels // the stretch. // +// DOCK-BOTTOM (single-window gauges): the world occupies the client MINUS the +// gauge strip (strip height tracks width at the panel's 480/1320 design ratio), +// so the projection aspect must come from the WORLD region or the scene +// stretches vertically by the strip's share. +static float BTWorldAspectOf(int client_w, int client_h) +{ + extern int gBTGaugeDockBottom; + extern int BTGaugeStripHeightFor(int width); + float world_h = (float)client_h; + if (gBTGaugeDockBottom) + { + float strip = (float)BTGaugeStripHeightFor(client_w); + if (strip > 0.0f && strip < world_h - 8.0f) + world_h -= strip; + } + return (float)client_w / world_h; +} + void L4NotifyWindowResized(int client_w, int client_h) { if (client_w <= 0 || client_h <= 0) return; - gWindowAspect = (float)client_w / (float)client_h; + gWindowAspect = BTWorldAspectOf(client_w, client_h); DEBUG_STREAM << "[resize] client " << client_w << "x" << client_h << " aspect=" << gWindowAspect << " app=" << (void *)l4_application << "\n" << std::flush; @@ -8579,7 +8734,7 @@ void { if (client_w <= 0 || client_h <= 0) return; - gWindowAspect = (float)client_w / (float)client_h; + gWindowAspect = BTWorldAspectOf(client_w, client_h); if (viewAngle <= 0.0f || clipFar <= clipNear) { DEBUG_STREAM << "[resize] projection not built yet (viewAngle=" diff --git a/engine/MUNGA_L4/L4VIDRND.h b/engine/MUNGA_L4/L4VIDRND.h index 94f8b19..5ddc47c 100644 --- a/engine/MUNGA_L4/L4VIDRND.h +++ b/engine/MUNGA_L4/L4VIDRND.h @@ -1910,6 +1910,17 @@ public: OrientationMatrix(3, 1) = y; OrientationMatrix(3, 2) = z; } + // Overwrite the offset's ROTATION rows with a yaw (rotation about local Y). + // The BT wreck-flame billboard (the 1996 dpl_SetDCSReorientAxes(dcs, + // dpl_reorient_axes_y) on flamebig) yaws the flame card toward the camera + // each frame; translation row is left untouched. + void SetOffsetYaw(float yaw) + { + float c = cosf(yaw), s = sinf(yaw); + OrientationMatrix(0, 0) = c; OrientationMatrix(0, 1) = 0.0f; OrientationMatrix(0, 2) = -s; + OrientationMatrix(1, 0) = 0.0f; OrientationMatrix(1, 1) = 1.0f; OrientationMatrix(1, 2) = 0.0f; + OrientationMatrix(2, 0) = s; OrientationMatrix(2, 1) = 0.0f; OrientationMatrix(2, 2) = c; + } protected: dpl_DCS *myDCS, *myParentDCS; diff --git a/engine/MUNGA_L4/bgfload.cpp b/engine/MUNGA_L4/bgfload.cpp index 8276837..049b8a7 100644 --- a/engine/MUNGA_L4/bgfload.cpp +++ b/engine/MUNGA_L4/bgfload.cpp @@ -295,6 +295,9 @@ struct MatInfo { bool hasDiffuse = false; // material carried an explicit DIFFUSE/AMBIENT tag std::string texPath; int texChannel = 0; // BSL bit-slice (BMF TEXTURE tag 0x18; absent = 0) + bool texScroll = false; // TEXTURE SPECIAL " SCROLL" (tag 0x2037): UV animation + float texScrollU = 0.0f; // cycles/second + float texScrollV = 0.0f; // RAMP (task #20): the material's 2-endpoint colour ramp (dpl_SetMaterialRamp). // The grayscale texture's luminance indexes a low->high colour gradient -- this // is how the IG board coloured terrain (rock 0.25,0.21,0.16->0.8,0.5,0.4=warm @@ -316,6 +319,11 @@ struct MatInfo { struct TexRef { std::string map; int channel = 0; + // TEXTURE-record SPECIAL " SCROLL u0 v0 du dv" (tag 0x2037): authored UV + // scroll rates in cycles/second (the firesmoke fire-noise roll). + bool scroll = false; + float scrollU = 0.0f; + float scrollV = 0.0f; }; struct MaterialResolver { @@ -333,6 +341,22 @@ struct MaterialResolver { if (ch.id == TAG_NAME) name = chunkStr(ch); else if (ch.id == TAG_TEXTURE_MAP) ref.map = chunkStr(ch); else if (ch.id == TAG_BITSLICE && ch.len >= 1) ref.channel = ch.data[0]; + else if (ch.id == TAG_SV_SPECIAL && ch.len > 8) { + // " SCROLL u0 v0 du dv" -- authored UV scroll (BTFX's + // firesmoke family). u0/v0 initial offsets are 0 in every + // shipped record; only the rates matter. + std::string sp = chunkStr(ch); + size_t at = sp.find("SCROLL"); + if (at != std::string::npos) { + float u0 = 0, v0 = 0, du = 0, dv = 0; + if (sscanf(sp.c_str() + at + 6, "%f %f %f %f", &u0, &v0, &du, &dv) == 4 + && (du != 0.0f || dv != 0.0f)) { + ref.scroll = true; + ref.scrollU = du; + ref.scrollV = dv; + } + } + } } if (!name.empty() && !ref.map.empty()) out[name] = ref; } @@ -442,7 +466,13 @@ struct MaterialResolver { if (tm != texMaps.end()) { const auto& imgs = imageIndex(); auto f = imgs.find(stemLower(tm->second.map)); - if (f != imgs.end()) { info.texPath = f->second; info.texChannel = tm->second.channel; } + if (f != imgs.end()) { + info.texPath = f->second; + info.texChannel = tm->second.channel; + info.texScroll = tm->second.scroll; + info.texScrollU = tm->second.scrollU; + info.texScrollV = tm->second.scrollV; + } } out[name] = info; } @@ -501,6 +531,8 @@ struct Builder { bool currentHasDiffuse = false; // material carried an explicit diffuse tag std::string currentTex; int currentTexChannel = 0; // BSL bit-slice (BMF tag 0x18) + bool currentTexScroll = false; // TEXTURE SPECIAL " SCROLL" (tag 0x2037) + float currentTexScrollU = 0.0f, currentTexScrollV = 0.0f; bool currentHasRamp = false; float currentRampLo[3] = {0,0,0}; float currentRampHi[3] = {1,1,1}; @@ -569,18 +601,32 @@ struct Builder { void buildPmesh(const Chunk& pmesh) { std::vector lx, ly, lz, lu, lv; + // Authored per-vertex RGBA (tags 0x0082/0x008A: 4 floats at offset 12). + // Values run OUTSIDE [0,1] (the flame card FLAMEBIG.BGF fades its tip + // to alpha -0.2 and over-brightens its base to 1.2 -- the DPL clamp + // convention, same as the .PFX negative colour ramps). + std::vector lrgba; + bool vertexAlpha = false; // any authored alpha < 1 -> BLENDED batch uint16_t vtag = 0; for (const Chunk& ch : pmesh.children) { int stride = vertexStride(ch.id); if (!stride) continue; vtag = ch.id; int uvOff = uvOffset(ch.id); + const bool hasRGBA = (ch.id == TAG_VERTEX_XYZ_RGBA || ch.id == TAG_VERTEX_XYZ_RGBA_UV); size_t count = ch.len / (size_t)stride; for (size_t i = 0; i < count; ++i) { const uint8_t* v = ch.data + i * stride; lx.push_back(rdF32(v)); ly.push_back(rdF32(v + 4)); lz.push_back(rdF32(v + 8)); if (uvOff >= 0) { lu.push_back(rdF32(v + uvOff)); lv.push_back(rdF32(v + uvOff + 4)); } else { lu.push_back(0.0f); lv.push_back(0.0f); } + if (hasRGBA) { + float cr = rdF32(v + 12), cg = rdF32(v + 16), cb = rdF32(v + 20), ca = rdF32(v + 24); + if (ca < 0.999f) vertexAlpha = true; + auto C8 = [](float f) { int n = (int)(f * 255.0f + 0.5f); return (uint32_t)(n < 0 ? 0 : n > 255 ? 255 : n); }; + lrgba.push_back((C8(ca) << 24) | (C8(cr) << 16) | (C8(cg) << 8) | C8(cb)); + } else + lrgba.push_back(0xFFFFFFFFu); } break; } @@ -670,7 +716,13 @@ struct Builder { px.push_back(lx[i]); py.push_back(ly[i]); pz.push_back(lz[i]); nx.push_back(0); ny.push_back(0); nz.push_back(0); uu.push_back(lu[i] * tileU + twist * lv[i]); vv.push_back(lv[i] * tileV); - col.push_back(vcol); + // VERTEX-ALPHA batches (the flame cards) keep their AUTHORED + // per-vertex gradient (white-hot base -> dark faded tip) -- it IS + // the flame's shading, modulated by the (ramped, scrolling) + // texture in the blend pass. Opaque batches keep the flat + // tint/diffuse rule unchanged (alpha==1 skins carry no gradient + // the verified look depends on). + col.push_back(vertexAlpha ? lrgba[i] : vcol); } const uint32_t idxStart = (uint32_t)mesh->indices.size(); @@ -729,6 +781,10 @@ struct Builder { : (useRamp ? rampTint : currentColor); // pure-emissive test keys on it batch.texPath = currentTex; batch.texChannel = currentTexChannel; + batch.texScroll = currentTexScroll; + batch.texScrollU = currentTexScrollU; + batch.texScrollV = currentTexScrollV; + batch.vertexAlpha = vertexAlpha; batch.hasRamp = useRamp; // tsphere warp flag: warpBands>0 signals L4D3D's ramp bake to CONTRAST- // COMPRESS the bintA cloud toward mid-grey (BT_WARP_CONTRAST) before applying @@ -966,6 +1022,8 @@ struct Builder { bool savedHasDiffuse = currentHasDiffuse; std::string savedTex = currentTex; int savedTexChannel = currentTexChannel; + bool savedTexScroll = currentTexScroll; + float savedTexScrollU = currentTexScrollU, savedTexScrollV = currentTexScrollV; bool savedHasRamp = currentHasRamp; bool savedPunch = currentPunch; bool savedShadowMat = currentShadowMat; @@ -1048,12 +1106,15 @@ struct Builder { currentColor = info.color; currentTex = info.texPath; currentHasDiffuse = info.hasDiffuse; currentTexChannel = info.texChannel; + currentTexScroll = info.texScroll; + currentTexScrollU = info.texScrollU; + currentTexScrollV = info.texScrollV; currentHasRamp = info.hasRamp; for (int i = 0; i < 3; ++i) { currentRampLo[i] = info.rampLo[i]; currentRampHi[i] = info.rampHi[i]; } currentHasEmissive = info.hasEmissive; for (int i = 0; i < 3; ++i) currentEmissive[i] = info.emissive[i]; } - else { currentColor = colorForMaterial(full); currentTex.clear(); currentHasDiffuse = false; currentTexChannel = 0; } + else { currentColor = colorForMaterial(full); currentTex.clear(); currentHasDiffuse = false; currentTexChannel = 0; currentTexScroll = false; currentTexScrollU = currentTexScrollV = 0.0f; } // TRANSLOCATION WARP: override the material's coarse "sky" ramp with the // NARROW LAVENDER ramp that matched the original (capture.png). The swirl // is the bintA cloud coloured lo->hi by luminance; a wide sky ramp @@ -1085,6 +1146,9 @@ struct Builder { currentHasDiffuse = savedHasDiffuse; currentTex = savedTex; currentTexChannel = savedTexChannel; + currentTexScroll = savedTexScroll; + currentTexScrollU = savedTexScrollU; + currentTexScrollV = savedTexScrollV; currentHasRamp = savedHasRamp; currentPunch = savedPunch; currentShadowMat = savedShadowMat; diff --git a/engine/MUNGA_L4/bgfload.h b/engine/MUNGA_L4/bgfload.h index 5dcd66a..5c27558 100644 --- a/engine/MUNGA_L4/bgfload.h +++ b/engine/MUNGA_L4/bgfload.h @@ -31,6 +31,19 @@ struct BgfDrawBatch { // holds blkhwk1..4); decoding the packed word as one RGBA image overlaid // 2-3 different sheets as color channels = the rainbow "graffiti" mechs. int texChannel = 0; + // TEXTURE SCROLL (the BMF TEXTURE record's SPECIAL " SCROLL u0 v0 du dv", + // tag 0x2037): authored UV animation in cycles/second -- the firesmoke + // family (flamebig/fire5 flame cards, the burning-wreck dressing) rolls + // its fire noise this way. Propagated to L4TEXOP::doScroll, which the + // draw path (d3d_OBJECT::SetTextureScrolling) already honours. + bool texScroll = false; + float texScrollU = 0.0f; + float texScrollV = 0.0f; + // VERTEX ALPHA (tags 0x0082/0x008A with any authored alpha < 1): the batch + // is a BLENDED effect card (FLAMEBIG's flame fan fades its tip to negative + // alpha). The verts keep their authored RGBA gradient and the draw routes + // to the alpha-blend pass, unlit, colour = texture x vertex gradient. + bool vertexAlpha = false; // RAMP (task #20): colorize the grayscale texture by luminance across // rampLo->rampHi (the authentic IG-board terrain colouring). When set, the // renderer bakes lerp(rampLo, rampHi, texLum) into the texture and draws with diff --git a/engine/MUNGA_L4/l4d3d.h b/engine/MUNGA_L4/l4d3d.h index 78376d5..55d6f9e 100644 --- a/engine/MUNGA_L4/l4d3d.h +++ b/engine/MUNGA_L4/l4d3d.h @@ -98,6 +98,11 @@ struct L4DRAWOP // black texels were loaded with alpha 0; draw with alpha TEST in the opaque pass // (z-write preserved), rejecting hole texels. bool punchThrough; + // VERTEX-ALPHA BLEND (BGF 0x0082/0x008A verts with authored alpha < 1): an + // effect card (the FLAMEBIG flame fan). Drawn in the alpha-blend pass, + // UNLIT, colour = texture x the authored per-vertex gradient, alpha = the + // per-vertex fade (SRCALPHA/INVSRCALPHA). + bool vertexAlphaBlend; // COCKPIT PUNCH STENCIL-CUT (task #55, i860-firmware-decoded): 0=normal, // 1=aperture MASK (drawn stencil-only, paired with the following hull op), // 2=HULL (drawn stencil-rejected under the mask = window cutouts). @@ -158,6 +163,7 @@ public: // frustum once (SetCullFrustum); Draw() then skips objects fully outside it. static void SetCullFrustum(const D3DXMATRIX *viewProj); // NULL disables for the frame static void SetCameraPosition(float x, float y, float z); // for LOD distance selection + static void GetCameraPosition(float *x, float *y, float *z); // wreck-flame billboard yaw D3DXVECTOR3 mCullCenter; // model-space bounding sphere (computed at load) float mCullRadius; // <= 0 -> unknown, never culled diff --git a/game/btl4main.cpp b/game/btl4main.cpp index 11a29a5..dd923cf 100644 --- a/game/btl4main.cpp +++ b/game/btl4main.cpp @@ -389,6 +389,30 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine } } } + // DOCK-BOTTOM (single-window gauges, 2026-07-12): under BT_DEV_GAUGES the + // gauge strip is APPENDED below the world view in THIS window (strip height + // = width x 480/1320, the panel's design aspect); BT_DEV_GAUGES_WINDOW=1 + // restores the old separate MFD window. + { + extern int gBTGaugeDockBottom; + extern int BTGaugeStripHeightFor(int width); + if (getenv("BT_DEV_GAUGES") != 0 && getenv("BT_DEV_GAUGES_WINDOW") == 0) + { + gBTGaugeDockBottom = 1; + // Readability default (user-reported: the strip at 800 wide is a + // 0.6x downscale, hard to read): a WIDER world region (the + // projection is aspect-correct for any shape) buys strip pixels -- + // 1100x600 world + 1100x400 strip = a 1000-tall window that fits a + // 1080p desktop with the panel at 83% of design size. -res still + // overrides both. + if (strstr(lpCmdLine ? lpCmdLine : "", "-res") == 0) + { + winW = 1100; + winH = 600; + } + winH += BTGaugeStripHeightFor(winW); + } + } RECT wr = { 0, 0, winW, winH }; AdjustWindowRect(&wr, WS_OVERLAPPEDWINDOW, FALSE); diff --git a/game/reconstructed/ammobin.cpp b/game/reconstructed/ammobin.cpp index 22890a7..acfb96b 100644 --- a/game/reconstructed/ammobin.cpp +++ b/game/reconstructed/ammobin.cpp @@ -170,6 +170,21 @@ AmmoBin::~AmmoBin() // to Feeding, reloads the feed timer to feedRate, decrements ammoCount (clamped // at 0) and raises the Empty alarm if that was the last round. // +// Panel bridges (Streak-gauge fix, 2026-07-12): the BallisticWeaponCluster +// read the bin's rounds via raw `bin+0x180` and the reload state via raw +// `bin+0x18c` -- the BINARY's offsets, garbage on our compiled layout (the +// HeatWatcher base sizes differ), so the ammo counter showed a constant and +// the reload wipe never armed. Complete-type reads of the NAMED members. +int *BTAmmoBinCountPtr(void *bin) +{ + return (bin != 0) ? &((AmmoBin *)bin)->ammoCount : 0; +} +int BTAmmoBinFeeding(void *bin) +{ + // "reloading" for the panel = the bin mid-feed (alarm state Feeding). + return (bin != 0 && ((AmmoBin *)bin)->ammoAlarm.Level() == AmmoBin::Feeding) ? 1 : 0; +} + int AmmoBin::FeedAmmo() { if (simulationState == 1) // this+0x40 == 1 (destroyed) diff --git a/game/reconstructed/ammobin.hpp b/game/reconstructed/ammobin.hpp index 915c5ab..ad6360d 100644 --- a/game/reconstructed/ammobin.hpp +++ b/game/reconstructed/ammobin.hpp @@ -78,6 +78,8 @@ public HeatWatcher { friend struct AmmoBinLayoutCheck; // compile-time offset locks (ammobin.cpp) + friend int *BTAmmoBinCountPtr(void *bin); // panel ammo counter bridge (Streak fix) + friend int BTAmmoBinFeeding(void *bin); // panel reload-state bridge //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Shared Data Support // diff --git a/game/reconstructed/btl4gau2.cpp b/game/reconstructed/btl4gau2.cpp index 27db968..9fb51f1 100644 --- a/game/reconstructed/btl4gau2.cpp +++ b/game/reconstructed/btl4gau2.cpp @@ -1673,7 +1673,11 @@ void WeaponCluster::Execute() if (getenv("BT_PANEL_LOG")) { static int s_pl = 0; - if ((s_pl++ % 60) == 0) + if ((s_pl++ % 61) == 0) // 61 PRIME: a shared counter at %60 ALIASES + // to one cluster when the panel count divides + // 60 (the Avatar's 6 panels -> only AFC100 + // ever printed; the emitter probe's %97 + // comment taught this lesson already) DEBUG_STREAM << "[panel] " << (diagWeaponName ? diagWeaponName : "?") << " percentDone=" << percentDone << " warn=" << warningState << "\n" << std::flush; @@ -1751,11 +1755,18 @@ BallisticWeaponCluster::BallisticWeaponCluster( reloadSeconds = 0.0f; // @0x3E jammed = firing = reloading = False; - void *ammoBin = ResolveLink((char *)subsystem_in + 0x43c); // FUN_00417ab4 (raw) + // AFC100-counter fix (2026-07-12): the raw ResolveLink walk assumed the + // 1995 connection internals -- resolve through the weapon's own typed + // connection instead (the same call ConsumeRound fires through). + extern void *BTWeaponAmmoBin(void *weapon); + void *ammoBin = BTWeaponAmmoBin(subsystem_in); int engPort = renderer_in->FindGraphicsPort(eng_port_name); - // Ammo count off the bin (+0x180); BEST-EFFORT raw if the bin resolves. - int *ammoValue = (ammoBin != NULL) ? (int *)((char *)ammoBin + 0x180) : NULL; + // Ammo count via the complete-type bridge (Streak-gauge fix, 2026-07-12): + // the old raw `bin+0x180` was the BINARY's ammoCount offset -- garbage on + // our compiled AmmoBin (base sizes differ), so the counter never moved. + extern int *BTAmmoBinCountPtr(void *bin); + int *ammoValue = BTAmmoBinCountPtr(ammoBin); ammoCountA = new NumericDisplayInteger(ChildRate(), mfd_mode, renderer_in, // @00470cfc owner_ID, mfd_port, x + 0x27, y + 100, x + 0x5f, y + 0x76, font_a, 4, @@ -1823,7 +1834,8 @@ void BallisticWeaponCluster::Execute() { jammed = (*(int *)((char *)subsystem + 0x364) == 5); // BEST-EFFORT raw (weaponAlarm==Jammed) void *ammoBin = ResolveLink((char *)subsystem + 0x43c); // FUN_00417ab4 - int reloadState = (ammoBin != NULL) ? *(int *)((char *)ammoBin + 0x18c) : 0; + extern int BTAmmoBinFeeding(void *bin); + int reloadState = BTAmmoBinFeeding(ammoBin); // was raw bin+0x18c (garbage on our layout) firing = reloadState; if (reloadState == 0) { diff --git a/game/reconstructed/btl4vid.cpp b/game/reconstructed/btl4vid.cpp index b6060ac..e631f9d 100644 --- a/game/reconstructed/btl4vid.cpp +++ b/game/reconstructed/btl4vid.cpp @@ -802,10 +802,16 @@ void // if (render_tree.wreckHulk != NULL) render_tree.wreckHulk->SetDrawObj(NULL); if (render_tree.wreckDebris != NULL) render_tree.wreckDebris->SetDrawObj(NULL); + if (render_tree.wreckFlames != NULL) render_tree.wreckFlames->SetDrawObj(NULL); render_tree.wreckHulk = NULL; render_tree.wreckDebris = NULL; + render_tree.wreckFlames = NULL; render_tree.wrecked = 0; render_tree.wreckAge = 0.0f; + render_tree.wreckRevealed = 0; + render_tree.wreckHulkObj = NULL; + render_tree.wreckDebrisObj = NULL; + render_tree.wreckFlamesObj = NULL; // // Restore every body segment to its now-intact mesh via the shared view-skeleton @@ -929,6 +935,16 @@ void } d3d_OBJECT *hulk = (hulk_name[0] != '\0') ? d3d_OBJECT::LoadObject(GetDevice(), hulk_name) : NULL; + // EMPTY-PLACEHOLDER guard: some shipped hulks are 153-byte stubs with ZERO + // geometry (THRDBR.BGF, FLAMESML.BGF) -- they "load" fine and draw nothing, + // so the missing-file fallback alone never fires. Treat no-vertex hulks + // as missing. + if (hulk != NULL && hulk->GetVertCount() == 0) + { + DEBUG_STREAM << "[BTrender] wreck: '" << hulk_name + << "' is an EMPTY placeholder -> gendbr.bgf fallback\n" << std::flush; + hulk = NULL; + } if (hulk == NULL) { DEBUG_STREAM << "[BTrender] wreck: '" << hulk_name @@ -942,10 +958,25 @@ void // d3d_OBJECT *debris = d3d_OBJECT::LoadObject(GetDevice(), "ldbr.bgf"); + // + // The burning-wreck FLAMES (the 1996 case-4 "fires" branch): flamesml + + // flamebig hung over the pile; flamebig is Y-BILLBOARDED toward the camera + // (dpl_SetDCSReorientAxes) and the fires DCS falls at -0.01 (slower than + // the hulk's -0.025, so the flames ride above the sinking pile). In this + // content build FLAMESML.BGF is an empty placeholder -- only flamebig + // carries geometry (btfx:firesmoke1_mtl, the scrolling fire-noise card, + // animated by the authored SCROLL now honoured in the BGF path). + // + d3d_OBJECT *flames = d3d_OBJECT::LoadObject(GetDevice(), "flamebig.bgf"); + if (flames != NULL && flames->GetVertCount() == 0) + flames = NULL; + // // Hide the body; hang the wreck pieces on the tree root (identity offset -- // the root renderable already pushes the wreck's localToWorld, so they sit - // at the mech's ground position with its death yaw). + // at the mech's ground position with its death yaw). All pieces start + // HIDDEN: the 1996 script's InstanceSwitchRenderables reveal them 0.25s + // after the boom (behind the dnboom flash) -- TickWreck runs the reveal. // for (std::map::iterator r = render_tree.segRenderable.begin(); @@ -959,21 +990,39 @@ void dpl_ISECT_MODE isect_mode; LinearMatrix identity(True); if (hulk != NULL) + { render_tree.wreckHulk = new DPLStaticChildRenderable( victim, false /* main zone */, hulk, isect_mode, INTERSECT_ALL, identity, render_tree.rootRenderable); + render_tree.wreckHulk->SetDrawObj(NULL); // hidden until the reveal + } if (debris != NULL) + { render_tree.wreckDebris = new DPLStaticChildRenderable( victim, false /* main zone */, debris, isect_mode, INTERSECT_ALL, identity, render_tree.rootRenderable); + render_tree.wreckDebris->SetDrawObj(NULL); + } + if (flames != NULL) + { + render_tree.wreckFlames = new DPLStaticChildRenderable( + victim, false /* main zone */, flames, + isect_mode, INTERSECT_ALL, identity, render_tree.rootRenderable); + render_tree.wreckFlames->SetDrawObj(NULL); + } } - render_tree.wrecked = 1; - render_tree.wreckAge = 0.0f; + render_tree.wrecked = 1; + render_tree.wreckAge = 0.0f; + render_tree.wreckRevealed = 0; + render_tree.wreckHulkObj = hulk; + render_tree.wreckDebrisObj = debris; + render_tree.wreckFlamesObj = flames; DEBUG_STREAM << "[BTrender] wreck swap: victim -> '" << (hulk_name[0] ? hulk_name : "gendbr.bgf") << (hulk ? "'" : "' (LOAD FAILED -- body hidden only)") << (debris ? " + ldbr debris" : "") - << "\n" << std::flush; + << (flames ? " + flamebig flames" : "") + << " (reveal in 0.25s)\n" << std::flush; } @@ -1002,14 +1051,40 @@ int return 0; // already buried render_tree.wreckAge += dt; - float sink = -0.025f * render_tree.wreckAge * render_tree.wreckAge; // the authored rate + + // + // The 0.25s REVEAL (the 1996 InstanceSwitch delay): the pieces appear + // behind the dnboom flash, then MakeDCSFall arms and the burial starts. + // + const float kRevealDelay = 0.25f; // static_debris/fires_delay + if (!render_tree.wreckRevealed) + { + if (render_tree.wreckAge < kRevealDelay) + return 1; + render_tree.wreckRevealed = 1; + if (render_tree.wreckHulk) + render_tree.wreckHulk->SetDrawObj(render_tree.wreckHulkObj); + if (render_tree.wreckDebris) + render_tree.wreckDebris->SetDrawObj(render_tree.wreckDebrisObj); + if (render_tree.wreckFlames) + render_tree.wreckFlames->SetDrawObj(render_tree.wreckFlamesObj); + } + + // MakeDCSFall: offsetY = -1/2 g t^2 from the reveal; hulk/debris g=0.025, + // fires g=0.01 (the flames ride above the sinking pile). + float t = render_tree.wreckAge - kRevealDelay; + if (t < 0.0f) t = 0.0f; + float sink = -0.025f * t * t; // the authored hulk rate if (sink < -8.0f) { - // fully buried -> hide + stop ticking + // fully buried -> hide + stop ticking (the flames die with the pile: + // the original removed the whole death entity here) if (render_tree.wreckHulk) render_tree.wreckHulk->SetDrawObj(NULL); if (render_tree.wreckDebris) render_tree.wreckDebris->SetDrawObj(NULL); + if (render_tree.wreckFlames) render_tree.wreckFlames->SetDrawObj(NULL); render_tree.wreckHulk = NULL; render_tree.wreckDebris = NULL; + render_tree.wreckFlames = NULL; DEBUG_STREAM << "[BTrender] wreck buried (sink complete)\n" << std::flush; return 0; } @@ -1017,6 +1092,26 @@ int render_tree.wreckHulk->SetOffsetTranslation(0.0f, sink, 0.0f); if (render_tree.wreckDebris) render_tree.wreckDebris->SetOffsetTranslation(0.0f, sink, 0.0f); + if (render_tree.wreckFlames) + { + render_tree.wreckFlames->SetOffsetTranslation(0.0f, -0.01f * t * t, 0.0f); + + // + // Y-BILLBOARD the flame card at the camera (dpl_SetDCSReorientAxes + // analog). The parent DCS applies the victim's localToWorld (its + // death yaw), so the camera direction is taken in the victim's LOCAL + // frame: d_local = R^T * (cam - wreck) using the orthonormal rotation + // rows of localToWorld. + // + float cx, cy, cz; + d3d_OBJECT::GetCameraPosition(&cx, &cy, &cz); + float dx = cx - (float)victim->localOrigin.linearPosition.x; + float dz = cz - (float)victim->localOrigin.linearPosition.z; + float lx = dx * (float)victim->localToWorld(0, 0) + dz * (float)victim->localToWorld(0, 2); + float lz = dx * (float)victim->localToWorld(2, 0) + dz * (float)victim->localToWorld(2, 2); + if (lx * lx + lz * lz > 1e-6f) + render_tree.wreckFlames->SetOffsetYaw(atan2f(lx, lz)); + } return 1; } diff --git a/game/reconstructed/btl4vid.hpp b/game/reconstructed/btl4vid.hpp index 5754add..33b6216 100644 --- a/game/reconstructed/btl4vid.hpp +++ b/game/reconstructed/btl4vid.hpp @@ -679,7 +679,12 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device); int wrecked; // 1 = swapped to the dbr hulk DPLStaticChildRenderable *wreckHulk; // the dbr piece (sinks) DPLStaticChildRenderable *wreckDebris; // the ldbr scatter (sinks) + DPLStaticChildRenderable *wreckFlames; // flamebig card (billboards, sinks slower) float wreckAge; // seconds since the swap + int wreckRevealed; // 1 once the 0.25s reveal delay elapsed + d3d_OBJECT *wreckHulkObj; // pieces held hidden until the reveal + d3d_OBJECT *wreckDebrisObj; + d3d_OBJECT *wreckFlamesObj; std::map segRenderable; // slot -> joint renderable std::map segGState; // slot -> last applied graphic state }; diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 118ef2c..0969ab5 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -644,6 +644,7 @@ int gBTMissileTrigger = 0; // channel 3: missiles (key 3 / CTRL) static int gBTLaserKey = 0; // raw key states (set by the keyboard poll) static int gBTPPCKey = 0; static int gBTMissileKey = 0; +static int gBTPinkyKey = 0; // key '4' = the pod's 4th fire button (Pinky 0x45) static int gBTConfigKey = 0; // task #6: HOLD 'G' = the weapon-configure button static int gBTGenSelKey = 0; // task #12: F5..F8 = SelectGeneratorA..D, F9 = mode toggle // (0 idle; else the message id 4..8) @@ -786,35 +787,19 @@ void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target, const Point3D &targetPos, Scalar speed, Scalar damage, const Vector3D *launch_velocity, int guided) { - // The weapon's GetMuzzlePoint falls back to the mech ORIGIN (feet) when its mount - // segment doesn't resolve, so a projectile appeared to launch from the mech's feet. - // Resolve the real launch port by NAME off the shooter (same mechanism the visible - // laser beams use for the gun ports), alternating the left/right missile ports for a - // natural salvo look; fall back to a raised (torso-height) muzzle, then the passed one. + // MUZZLE (muzzle wave, 2026-07-12): the passed muzzle is now the weapon's + // AUTHENTIC mount segment (GetMuzzlePoint reads the real segmentIndex -- + // the old raw-offset garbage that made it resolve feet/heads is fixed), so + // the port-name rotation hack that compensated for it is RETIRED. Keep + // one safety: a degenerate resolve at/below origin height (an unposed + // segment) launches from torso height instead of underground. Point3D mz = muzzle; if (shooter != 0) { Mech *sm = (Mech *)shooter; - static const char *const kPorts[] = - { "sitermissleport", "sitelmissleport", "siterutorsoport", "sitelutorsoport", - "siterugunport", "sitelugunport" }; - static int s_portRot = 0; - EntitySegment *seg = 0; - int n = (int)(sizeof(kPorts)/sizeof(kPorts[0])); - for (int k = 0; k < n && seg == 0; ++k) + if (mz.y - sm->localOrigin.linearPosition.y < 1.0f) { - s_portRot = (s_portRot + 1) % n; - seg = sm->GetSegment(CString(kPorts[s_portRot])); - } - if (seg != 0) - { - AffineMatrix mw; - mw.Multiply(seg->GetSegmentToEntity(), sm->localToWorld); // port -> world - mz = mw; // W_Axis translation - } - else - { - mz = sm->localOrigin.linearPosition; // raise to torso height + mz = sm->localOrigin.linearPosition; mz.y += 12.0f; } } @@ -834,8 +819,15 @@ void d.x = tpos.x - mz.x; d.y = tpos.y - mz.y; d.z = tpos.z - mz.z; Scalar len = (Scalar)sqrtf(d.x*d.x + d.y*d.y + d.z*d.z); if (getenv("BT_PROJ_LOG")) - DEBUG_STREAM << "[projectile] PUSH target=" << (void*)target << " enemy=" << (void*)gEnemyMech - << " len=" << len << " speed=" << speed << " dmg=" << damage << "\n" << std::flush; + { + Scalar relY = (shooter != 0) + ? (mz.y - ((Mech *)shooter)->localOrigin.linearPosition.y) : mz.y; + DEBUG_STREAM << "[projectile] PUSH target=" << (void*)target + << " len=" << len << " speed=" << speed << " dmg=" << damage + << " mz=(" << mz.x << "," << mz.y << "," << mz.z << ") relY=" << relY + << " lv=" << (launch_velocity + ? "(auth)" : "(fallback)") << "\n" << std::flush; + } if (len < 0.001f) return; for (int i = 0; i < 64; ++i) { @@ -854,14 +846,21 @@ void int haveLaunch = 0; if (launch_velocity != 0 && shooter != 0) { + // FRAME CONVENTION (telemetry-verified 2026-07-12): the authored + // MuzzleVelocity is (0,0,+100) -- the launcher frame's +Z is + // FORWARD, but the mech's body frame faces -Z. Transforming +Z + // through the body basis launched every round BACKWARD (then the + // seeker looped it through the ground onto the target -- + // user-reported). Map launcher-forward (+Z) onto body-forward + // (-Z basis): negate the z term. Mech *sm = (Mech *)shooter; UnitVector ax, ay, az; sm->localToWorld.GetFromAxis(X_Axis, &ax); sm->localToWorld.GetFromAxis(Y_Axis, &ay); sm->localToWorld.GetFromAxis(Z_Axis, &az); - p.vel.x = ax.x*launch_velocity->x + ay.x*launch_velocity->y + az.x*launch_velocity->z; - p.vel.y = ax.y*launch_velocity->x + ay.y*launch_velocity->y + az.y*launch_velocity->z; - p.vel.z = ax.z*launch_velocity->x + ay.z*launch_velocity->y + az.z*launch_velocity->z; + p.vel.x = ax.x*launch_velocity->x + ay.x*launch_velocity->y - az.x*launch_velocity->z; + p.vel.y = ax.y*launch_velocity->x + ay.y*launch_velocity->y - az.y*launch_velocity->z; + p.vel.z = ax.z*launch_velocity->x + ay.z*launch_velocity->y - az.z*launch_velocity->z; Scalar lv = (Scalar)sqrtf(p.vel.x*p.vel.x + p.vel.y*p.vel.y + p.vel.z*p.vel.z); if (lv > 1.0f) { p.speed = lv; haveLaunch = 1; } } @@ -882,6 +881,14 @@ void p.damage = damage; p.guided = guided; // autocannon shells fly straight (no seeker in the binary's plain Projectile) p.active = 1; + if (getenv("BT_PROJ_LOG")) + DEBUG_STREAM << "[projectile] vel=(" << p.vel.x << "," << p.vel.y + << "," << p.vel.z << ")" + << (launch_velocity + ? " authored=(" : " none=(") + << (launch_velocity ? launch_velocity->x : 0.0f) << "," + << (launch_velocity ? launch_velocity->y : 0.0f) << "," + << (launch_velocity ? launch_velocity->z : 0.0f) << ")\n" << std::flush; return; } } @@ -1693,6 +1700,10 @@ void gBTLaserKey = focused && ((pAsync('1') | pAsync(0x20 /*VK_SPACE*/)) & dn) ? 1 : 0; gBTPPCKey = focused && (pAsync('2') & dn) ? 1 : 0; gBTMissileKey = focused && ((pAsync('3') | pAsync(0x11 /*VK_CONTROL*/)) & dn) ? 1 : 0; + // The pod's 4TH fire button (Pinky, 0x45) -- previously + // unmapped on desktop, so any weapon the authored groups + // put there (the Avatar's NARC etc.) was unreachable. + gBTPinkyKey = focused && (pAsync('4') & dn) ? 1 : 0; // task #6: HOLD 'G' opens the config session on the selected // weapon (BT_CONFIG_SLOT, default: the first weapon); while // held, the fire keys TOGGLE its group membership. @@ -1711,7 +1722,7 @@ void gBTValveKey = focused && (pAsync('C') & dn) ? 1 : 0; // gBTDrive.fire = "any weapon trigger down" (feeds the bring-up // damage dispatcher + the beam-visual keepalive) - gBTDrive.fire = (gBTLaserKey || gBTPPCKey || gBTMissileKey) ? 1 : 0; + gBTDrive.fire = (gBTLaserKey || gBTPPCKey || gBTMissileKey || gBTPinkyKey) ? 1 : 0; static int sPrevX = 0; const int xNow = focused && (pAsync('X') & dn) ? 1 : 0; if (xNow && !sPrevX) gBTDrive.allStop = 1; // edge -> one all-stop @@ -1862,7 +1873,7 @@ void gBTDrive.fire = 0; gBTDrive.fireForced = 0; gBTDrive.forced = 0; - gBTLaserKey = gBTPPCKey = gBTMissileKey = 0; + gBTLaserKey = gBTPPCKey = gBTMissileKey = gBTPinkyKey = 0; } float throttle = gBTDrive.forced ? gBTDrive.forcedThrottle : gBTDrive.throttle; @@ -3251,7 +3262,7 @@ void }; int want[4]; want[0] = ((gBTLaserKey && targetInArc) || autofire) ? 1 : 0; - want[1] = 0; // pinky: unmapped on desktop + want[1] = (gBTPinkyKey && targetInArc) ? 1 : 0; // key '4' (was unmapped) want[2] = (gBTPPCKey && targetInArc) ? 1 : 0; want[3] = (gBTMissileKey && targetInArc) ? 1 : 0; if (cm != 0) diff --git a/game/reconstructed/mechweap.cpp b/game/reconstructed/mechweap.cpp index bf713fe..efdb8ed 100644 --- a/game/reconstructed/mechweap.cpp +++ b/game/reconstructed/mechweap.cpp @@ -572,12 +572,16 @@ void void MechWeapon::GetMuzzlePoint(Point3D &point) { - // @004b9948 -- resolve the weapon's mount segment (its index is the inherited - // subsystem slot at this+0xdc, == binary param_1+0xdc) in the owning Mech's - // segment table and transform it to world (FUN_00424da8). Reads live each call - // (the phantom cached `muzzlePoint` member is removed -- it collided with the - // Emitter subclass's own fields at 0x3F0+). - int segIndex = *(int *)((char *)this + 0xdc); // inherited segment index (byte-exact base) + // @004b9948 -- resolve the weapon's mount segment in the owning Mech's + // segment table and transform it to world (FUN_00424da8). + // DATABINDING FIX (muzzle wave, 2026-07-12): the binary's this+0xdc IS the + // engine Subsystem's segmentIndex -- but the old raw `*(this+0xdc)` read + // OUR compiled layout at that offset (garbage), so every weapon resolved a + // RANDOM segment or none: missiles launched from heads/feet/below terrain + // (user-hit on the Avatar) and the beam/missile paths grew gun-port + // name-lookup hacks to compensate. GetSegmentIndex() is the named member + // [T0 SUBSYSTM.h:108] -- authentic mounts on every chassis. + int segIndex = GetSegmentIndex(); BTResolveWeaponMuzzle(owner, segIndex, point); // mech4.cpp bridge (null-guarded) } diff --git a/game/reconstructed/projweap.cpp b/game/reconstructed/projweap.cpp index a92b85b..dcfdb3e 100644 --- a/game/reconstructed/projweap.cpp +++ b/game/reconstructed/projweap.cpp @@ -196,6 +196,18 @@ Logical // AmmoBin::FeedAmmo). Returns True when a round was dispensed; otherwise raises // the NoAmmo alarm and returns False. Shared by both FireWeapon spawn paths. // +// Panel bridge (AFC100-counter fix, 2026-07-12): resolve the weapon's ammo +// bin through the TYPED connection -- the gauge cluster's hand-rolled raw walk +// (*(plug+8) twice at subsystem+0x43c) assumed the 1995 connection-object +// internals and returned NULL/garbage on our layout, so the panel's ammo +// digits never bound (fired shells consumed rounds invisibly). +void * + BTWeaponAmmoBin(void *weapon) +{ + return (weapon != 0) + ? (void *)((ProjectileWeapon *)weapon)->ammoBinLink.Resolve() : 0; +} + Logical ProjectileWeapon::ConsumeRound() { diff --git a/game/reconstructed/projweap.hpp b/game/reconstructed/projweap.hpp index 17f371c..6985fb4 100644 --- a/game/reconstructed/projweap.hpp +++ b/game/reconstructed/projweap.hpp @@ -320,6 +320,7 @@ class NotationFile; int tracerInterval; // @0x438 resource TracerInterval (+0x1BC) AmmoBinConnection ammoBinLink; // @0x43C embedded 0xC connection to the AmmoBin subsystem + friend void *BTWeaponAmmoBin(void *weapon); // panel ammo-bin bridge (AFC100-counter fix) // (ctor @004bcbb0, dtor @004bcbcf, link vtable // @00512424; resolved via the resource AmmoBin // index at +0x1C0) -- LAST field, object ends 0x448 diff --git a/scratchpad/bintA.png b/scratchpad/bintA.png index 1b74aa95bf2a18b1122154fcb35c7cac001f1a66..6b811f1c40672f191bb52ef546e27f4d34490c2d 100644 GIT binary patch literal 7164 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 literal 5050 zcmX|_c{rPC+r~o>dn{Gd5_@L4P&JAop^B=l+UcOw`j(^BT5E|VvDQvoZwD<&N2afK zhN7mH7O|v3M=hm9qY_IcwN%n1WY6d8jQ;Z+&tKPZ-Pip)&+B}i;`2W4O7e%~0RVuK z$JsL%004=-R|$aZ-V=b(-hBxGsONZ`asD~2Zma%tqRyylzIwTtTz2PQ?>=lmQu2o^ zs8q%Vw{VO7?yx-mZUBTmh%Mo0N zY5m#D8@R-y*Vf2XksV_v!z0vG==sj)UC6uk-76WS+Riw^Vtm;}5AeMZL9IWoLO37e zv2BvY>d5>3eoG`ZvYxN|9%IMe4)P$I2se{@m^=02#dC+R2bUbGZXi64En_nK$yIHR zov}6A8LwL+x2>#YAf_L+=77(>l(pZlw6Zb>d4Zp|!fZKOy46XF!+m8*Ii%(ZYsR)Z z23Vi8mUKxhqHIAIMVrh~(vDE>JWj&3-1DQ3X4B)0l?~JQ%VkH9eTRIl#G=kk(UXLyz63lr;RX2UvvT&K4cNxlcUtMiY{9&it69z#{w5pdxs79s+Bw}t;_-nU z0$$5K&VkqEzlhuvjtlvZaT6b2Mr-i?qyDn-+K=A`zzYi&kvMh0 z2;eZU0TnS2*865S>ho@qx5{+C&s|}8UZqWYEHS7>aX4@X>LipoU~PWC=Fq8Z>(r>= z@ZlDLO^O3?_*WD=LmlG#8!+vyu)Dkxu@=yK1MJlcpUV+fj`#2OYc4%tT)Mt$a2t2E z_xSF~L_JTERH;bn2|0W3+R-}dV8Ud982OW1@#7`;_CA~j7RVopR*g`sYSonS>gx;% zBe@D}Do*dlkEvrUlH`IX%we8h&(lc$C{3>Y;xOBXr~U{D+@t7n*^vE;0!eI{DLMP0 zoct%@vsr{p(|Zu4GFY6@B&6j0REruw48@jX2br8L2-SUqM z<-KOBk&o^(3CrKQGKd8E>qMFgne}3{Td+zjAk*m9Ym?!SLH@fvX7vClz{a+u zSpG_W++f=%SXYu-JJ(fIv+~EjS}6ruT*e5PDCG~XFoz`*9=xWPfpa4q59hs%3=#{HhyVz zx9#8@9Z2Vo>MV!eA^rV9l5SV5YhvltDhhLSsz1SXd8Tdtm5`8&n31kb%8!9mqGb!e zLoWr_v=7tkM&1IXNOoBFIdGAQ1@Q`$o$PCA0Gbv{F2?#r=8WP67kt7K!>D@qxf||6 z7g{p?E1|ncN4EgAC+>e{q$PL-dTRe3WXmQV( zV$MM8|HSi%QqVB)IZ7p9k<%WmnO+*xZ*B-0Nd3Ux*Yyje_DMh*p889COclJ2E!^uz z<7k6Vnr0TTKMo1Y%3vuC3$QT?$Q^C~`EK#V#pYpxL!|4Ngie$;%)ia-aLWwYa8758jNrscZb&WU%@ zG>uu2KZ*?h?linp=}G4wD#W(Q{f_{zgGcPr#ZTBv0mI=f)qY=8%ySwl01we=dfkJC ze$A>UN3_VRnpQGr$r63Q^xVbU-z`RM!`?cDMs*JjEvWPfRlS~3%)lNQfkxy-`j7C# ztnOswEBvMZTGiHED3ccM*?-JHbhbylK%!mgDoKyr)LMqjXlr~Ua_@{9F{ffoV~(U@ zJD^FDVfQ$P22Vz?@l^VJl5p6Ul17v$q#QWh1tS`RA`$A2gACP4V~!%{X%$>~tEC(fou<``b zZ#Rp@8wr5JxJR#-7em!r3PZ;}_+V7&ihWl}xmw0Aoa!KsLxTKG*Lw?}_A4FcNJIiF zPHd0!Dp6qb(Ppb^uN?nYuexj)pd`d7aJmzLg~(YBA@Dn&6>{}YKLPba9d=d&2`Q*WBo^Te$p=!p2lg;t2ytT&>Rif%&V5D+6THfngpd2Hppvk1lk*<`q4vo5hjVbqbz!kaEEWk_ zc(Wtj>lT1*TUyc@fR3YGo5TWdy|xC>Kmaw~an;IBCW%0|*}-PI?F-ks8^+pcjIBR28e3e1jMrTkiwxZTR8ocaaOaoa3HhhsN8DEzK$&^`#j3mV$ z+cWHMm43d?PBMq;E`>kr*%|7LbhAvpeQ~9g;N^6^&x zfzBlYDFo`7rum5%Z)T1!2R%AMx9|9lHesY-?rdu@T)3zu)Fqkc4f%>gj=$k~dcoT2 zE#Wr=Wk)94b#Z91&$wN_y@MWC+*9d;MHa0@HJ?rdm43^Wdtq%tm1(b+aE;obfEK<~ zdd16YQuV6p-)kc@t33;GcNVnRk%0%Mo&ZVEyFz5c6SVJ2d25 zLwi)ZHa#6<=J*3=VQHkNTRCw9?|-Yd?Es!Kiq36y+yFlRa;L{+c-1P<9e}I+V79D; z=L|T`J@xC$jZ{-*N}Yu1T1Ai4`3*rq<-U{(_diAPW<$KLivqRClA@{GX` z5vjF03umqvS_4^vmx@TWx!T?$y8RPvgIMPU11@mPCmZ+k_cZ`vZ}wN?ieLQN?KpID z?Z7nIJ{qjs1fSHy%RU#wYd{z};>nK@2kjhk5fq;3bq(BQ&DOO%emJ?b2&fh`qmwj+ z*$+_<7#UwZN2 zOYmU(6bXNX*iEQvSyYev+Z!c06X&#dSd>0P(g!;p_o>Ig(Vfv;O)zU8rCG|hXu-}W zW0VuQ?v_?%wNjoKro#=D8_9xHmSWP>?JQ&o(P*x+G6x54RP|vVUaME;Em!ZaPNckI zeb=112;Pm_q(<}%D%2r_(x}d<$ZUaLFCYbWup@u4u;0dje3pFPwJG;EtY7Yt<~+l`SD4lT>nhuw$YoY;GjJ9fJn3;2 zOIeP*LTKndC>mvc_Gwo)!@!fiDz7NpzbVkH&MJz7DEj8g!m|gbaMT6c0}wP5GXE#ZiNW0VD#Hzr0r z*MHOkXSm+r)iIpmm6cP`CUye4;Hlm1>K+c9fyzX=>Z&|6;~RocmKgQuom!&k^Y@1P9&lM6NGksWr;ija%^dBm}QHcQ?+V zq6Qco_1Zc%6S;hsg08{$ws5OZ!?1zzN02wa?Pt9inY%h+S3ogiU*`+-#F4A(V%3=A zLXS4S4Z*<9q00`6MMkWGmrN)5D@1B`>6sgGsy${q52e9Ni+xHP!o%y;(>6rbzoF>T zz|&|T8$ZU3N`WG|flZ@L%`9o6P9-FQ06;i(*~b?@@kW;~17@H{*45DEq*TkH{0B>A z+EExoj#S~KaVgEWgZ3|LhY^UioD0LH6TLd0mT7hYc`fjErFea=4X?ZnIlT=gImV<= z^rt{7v5af@uXGJ09WjgLh29$RWI0~8Ep(o^wW*rs*0}GDkM*kq3<9p+JcibAItsBD z^T!ucGEry^|Ekvz ztjT<8^mesy#3Hjkv{$d<>$CmwU(2Y1j)`zA$*!VgEyzRr5r@|91on^d=bF&Lk_h)T zr3<7ypHbd#jWyw!s_h<6-0y8wPgqXV%+?=H#t_XL`}%Ys8H@XHtfvPs2dq+HOqpN( zFupj;ud-uM<1ZEU-c9IJPMp`AjTzaCRlbm1>&ppTY;hN7_H!(`kM*qikX(0(b1&(%g#|1 zDx^HnnF_k)Eh(YLpOtSN4Av{R8$_TCU|m&dHstJEdKMl^c5kWL-7jcrRzwzL;GDO` zkCO*KJ%JuiLu!Tl5(3x{yp&WkMOA*~mcv-6#7SvudGLUHHW{=F3d)BWJ^#YBPB$3x zUkHHq+N6eaU=R0ou7--tYK(*G>P0cn&kfaq(`Cc$`xO=oeSIpz`&)9W>e^v_q4}Mk zbQ4)##fWHu_m?||3N-n0W6n~1)=z{&XPaZW7T-TIOhZz28QB{nLv0~scfI}>-KX$k z*qKdC-=iRirU;yX-~eZgBV78q7l}Zk@~8SqY2qi_uvsF(-tsqrE~JCbVJJfGVF8+9gTUVu`||_H3nfw2RP?93}6ELC^fpKdc~0H82UrI5}_X65odA)cNn zj7x4ls#{uvCtrQ*lH+(e^8C-nrU#>UW|c~C-I86haJ^8yX@~i?MxlqL`IIAn2Xc5S z)yZXt**H*y65SBcA3*$J>hb3BX%W5EnpG=+JrC!Iueb%_Qkfxv7(qP5=Ox%(9JQLs zp4&2(Ex%X*6w&%5*-9BpD21O$NlE!D(`+FVZNYkpNY(=hG68$aef^>%qg}mh#mysD zKe&GX%;@SvxBTdPU~ZW+8p__D=bKC?WZm0us~Q({{njP4G@F}fEKmsqE-X{D1A4N( z{nhr{mVgY4-bIk)onAjMmn;^p%r&I%V8>1q-CkDTaZ7j6)0pc6wI3yOcCg_zjWQK7 znjdwd1Jx-*_qc`ST-sL_BG|6lGCiG3mbVXhFpS#Xbbj@p|A`f!>?$tGws+W`_B`At z`ld{P89U;4D<0yA>~#wv4CQ5^adw-3D(!lR*4bM7O>N<&mkZ+FE@U4$Jnz1^A2l47 z4rX0kP91x92`brWd4%9+(Tm>3-pQBjr_$E>2w8bsq{DBjvC6kn`n{Ek_cz*e*G?daWt=?y|M3?|jB&PcY|bb+Ez}=UiNt6JES! zdi)8${jN(1V~xU&S?^?58D7s8Y_KvJNTPT~RHJ-|5Pk2!Z7Aqsr#B2J?WR14lviT!cU_O}=zI3iazza~$xiaK&Cbzr!S>3@s2$-1iiOS6ZuG^ zJTYYhuYMt5xq^$I22LWNPO+6*OI=yNdO`}=ha3r*X^ts-gP2#hvfm^Xr{1bvxQpu3 z7|$iYAAeG_wGp?Pe2RoQz=6~L{6uKYOV1Waj)s`ed3Sr37BJ^`98!?N1#zy(%vY_- zm2*Eq^L^ZNj&?E{&ui3aOp}yqjU4Syx$$7g=B3i?L_mj*i2BRJZ;s9rJW#;uZ7$c^ zE+?D3^VrlrfO)4rOlC%K&nueWmqN zTf0n8jn0_2YFMHE*uePp|FJ;j_16?8M<=rIMW1s zWOKWfbGO}Du7S=LAO~3k?`iV6za@PPye!XYOqlNI{2qspy*tX}@I%fzWp!kgnMs4n zGA?ghxbj5^s$VFf;AlOeyxG$qiCI=a-q~4!a2ZpCH^s+3ZS3jSq>qh07z%%;n0!iO zYG@WhDbI3X9vCLBoqX^?``p`FXq;X6l*oQ=YaJ1hln}UI5-dcNjcJ;a{Hu-}78a+P z9&lapnc?q!p7p_uKkrNK#pQRGEN{QGO>7=ddo)%mbLb&v4ZO#pQ#u&3Fxa-_G|f~Q z8)RsJhU~Q~H8TN+=@fk3>9qUZf=Ayy8?(5MR`LnGIt3z}56)KS^Ge>nHaaka{H$yL z?iY%iIqy&`2~hk2lROg(U`chh7#o)zY0rnQ4TUuts<@la(woH%eEA1nTJgZ&4sF=v zg;02cjpcxP|16LyyQ!u@bk&M{aay|ueAcN+;Vo7Sno+O-4Qtg^xBqgM=x9MUrHsm7 zR-8CIr(^C%K(X+YW93)AVeD{m*o7nvAxdNwitos+3`xoIQk2~A&Yj1eD;wL)f|oli z;@`;UbqiB8j&BM>hWUDczBX#sE^0Q1d*;^+ss8=}*X3WTR;*5tdVB(0SjPAZ=kt&$ zKtjfSCqI5f-o&NdZoyCc|3y^s-{JRrjLIk+B3VALfF$!)u0w5cHUeq$&y9m3b+ZS6zQ1X z-HAg^9Iky*v5}Z<=i2~llTaYPA8kEWk-|Rq*STW>ETk`L^x(cm z%+CIzr0S}jug3}}dK>x+QF@?dPWGJne^lmwosVaV&nRjS=lYq5mI-uufhxZ=u}de{ zi14DCk?cxEd0=-a&=RxyIq&d^3nj2j?g|B861U0ZV!-d3 zzpF5-iYxx;a1U!utje;@>$>gt_#FKQG4denuOV2%eaO$*X60O=If+cLo_K@rIS4k0 zrbl*1_TKKuT!e1TlW=X_%s2+@%a#@fm_m2y=EyZ^ zX)I-ZFyNJB=+o4JA5SgA>067e#;rdYqt6EAv*<7r#tMUW;~~9n8$`cSWimSV%3T*o zfHP-Z9n2Usar-HI0{CPiwWmF6Z2xk(&;6)fCpN^e4{tTdQ4D@mU2kIJjDp3I!`Y*d zc2}xA8e@S?Ymh~~1r`?4e4jpm!Tp0}Uz_5izO+!@+uJ%hej3xJR8Yl;E_tuZt)v@r zJ&6`F1?2MiwrQRlJ$0kzeaJxqf62NGr{JOyXTSM~ye zb6dup2p4QXElQ9YS{U0o~Wf4d0+roVBmZlXh{ zQ*fgKL^fkb)Zzf#=++BaUG>!9tL~NZ?Rzfvi0e?N($U}$D!Xu8fgWMks}HRUI|tV0 zCh64?9$?;56H;v0+k10X>xBEcS3CZ^Gg~i&^1>!Zpu`szOrstuUdxjXkv6O6I^f@E zw82xE2FLPZQbt9}19kyJt}#{P?nG%2yr${*5g6+RNp!Peau!S-JD|nMHpR@IrTEI& z@AmgDg^PC&{4|0QCVneP8&^;q&M0ip1&~SHt@l`AHM=zsh-IXF+p}=`_=KNjy9UyI z!`}H|HdQy{|WlY~qA!mpS_A8p2C-}{nME}$H5k7xTmXo~@DsH~*WcRzc^yf=M z*sLZ=J9@Y~?RTD{nq}hQQ}<#rUV(|x?&2NneeY=}ZwXOmYr>Yb*?ciSv0}fjb(ET2 zs9{sI4b_=hQEcu27_S>=P>%4D*`jC6dcW>7DRLa{sltT}vCd_vBuPXfX$t*u%qOrL z*Nd76Qm*aM65rb8a=nWTB<`)S8Y7{qKvqLI6}Y)#De$qFmaiiSY?seL5*ik9M=?to z+Nd=CAT*X+KLW&Xl>@X4f?uy^O}x(^rqOnDbGE~0om&GI!q!xG*i~(aTvHt%UBxI( zN@GTw66=)UhNdTZyFcPW0qUfB*`|&@M%FJm%NTa@Db3l``LJt$sIYrWa|c8$Kx&tZ4S^o6NU~5=*4mkQ zYs&rN>MRrO@p-`}azr(bt{HgNx0&U7wB|#6P~K}B$0dPJTv94)_K{CBV^$L66B}sP zto&2)c!;7&WaDV**ZTrJAm2nOxa@_I(?yC#QLTEg&|3Id>IHZ;A2PC`sr4fmk;Fet zGC;usxNDE)bLMIgzcG_rhH61l;_luyd2jypKeJB1lwT6T8LLv2$N2LX|Ak&$NQX>; zu$cXgWqJIl#9`)x` w-Xcp(%t+-+24?LRKj^vAWks^Q!Rf|a2pk4i}PveaLQ4^VGWH+S{T?Y zJF2y>NOm4F>glS>8Fc9(4Gw_rtxcLkomL~Pyi*%!Km_p|f&-*0h8`uX_U9qqM5 zH*ig6im;6ts}x!j5s7Ou-#?8kVtEq}Q|+Hr|6X%goIXk8X>SZF82C8ANOP|)AJ4bD zh|;E!4{oc;ksXZ~D67KCN87C=2qxKu6W7X~3ua?ry-QCMCcj{V*A z%Cnh;e3DmzkKMrg?T5xbrc`&&O4zP{7V|I|koUaCUMy6Hd&ee3CY_nuFv8!k!3tb! zr@34=XxCZjlEqzkO^d-82A$jpipykL4>W`kvY5Cy%X(ziz4Xh|(@l_?_>n|HXvnd| zV|G2^>BQ)2s2k0nHW^guH_7Tj+1dHa-WLY%M*x$Pal@RVoqF78)~~MBACq3~pc82p z5=y#k-ou3I(iTZt9m@DU(+B1) zcyBr>>sp6C;U8o0}N~Tg<%XTWLgx2GZvs=LG}=cE|Ky z;}V|m`u6lqpz9SE2f7p!fIpkZ=M!pY9c%Yp(Hb>0zLZ5#il=FQ>Q*dcDXRF3i8bV% zh2J7~%0zmjl%1j)(V(X%E~1U0SLh_OIvi0z)j!MGRCLWjvNsb3x{+z;&x$EML|Y_7 zEBP;q@O@T-hY&5o_F;iHYD$1Zlro%Aad*_Wa1QRN|Y@Qd<_vf4J9Rq&ZQ*dFsX8j za|tK4GG~X;F}q;-NSG2(m6b^G{GeJxXrlldCM6t1R>ML{39pienWqA{VI)=U9`LuX zCIgll^Xjn@+*lztH)A7D4(Mhadf2T!s?fZ0=mCVUVIxRuLdnb)m$cy*RS;! zd1^wief?dFbH`->!!YtV-sI{?3C5Zy zOE@lbf7Evn*7$=zcHg}2HtDvcYy&T=En^m9xGtRS?^Dyy-_;;&tEQE;tuNN+&kO<^ zbDW&$&*-bfTYvU?vc6^L+OoD4U!Z;LeoMx4m1b2k3&I{k1&S;DXiNVnWRdRN*oUMZK|^#F{Wj%-?e9PB z5-%?HFI%VB1SDi8lpV~BTMDU98-!qOw_TViaEpgs%jm0jbS$tcLtF%~k3y%3Uv0vi z^?$ISQ$3z1PpTWoJ61}&yAhDlr`#jMYV46uYY5TVyc>vq?}5y(fX~~+Wl7f2EBn~* z-tf1CQQV1S`j1Kcw)Oq{(-O zl*f|zi>|C5P`<6@j7HR?^H`}Gk^$#gc{J9&@sH#NIIvtFcN`hJKP$SD+A3)PlyQGK zD~O@H-EbJBZ1K-WB@FiebgZ04awaa zzU^$hhyHueM#*(q9dkL!Y{gaZH=p;@pJP~2*H&@^o&R_Z` zqjTiNBWa_a>64YL6OSTrcMNw9`BrlM;g$d`ih_1C~)+ud1IwT;jSp8!RmY zHv=;A_D<^4C=QNb1j^s=1cr~$K$kj;k6sF&isPB_ipS*wSK<~pq{sJGcmckGa2@x! za~bK>J7B9nm+4ylL2Z8u0UZRqocvZZ;!W(BT$>|=U}I>*Rdn^cE3;#Dpez}`^){S+ zWh{&Y&(zGi>P4sv?=*|hB=4N#@_%k99`~DuITs|o66kM=1HR(K*9x43W@I;taH1w) zEP-~#sW70LEpN{H!9RgeAsV!jyE1DD^fHt*w7d?_{jFyjmuw1oLJW|tZ*}t{DMAzjSC?9#}yk0{PjVP8D;ZI=8FM6 zTAGqdPBKR~C{8s*4qCH$wK+h%0XK;1T;gPkA%!L6Y5BYe+SrIH zEsP-$GB!%Lb`M>f%*I2;(NxA+b_^Il&8|q>p{|_hr#KX(Ax7KTloCVt{?n0X>*YeU}aqC0XJ=xDv=T2sS+@48BTu8-Vn z6=287R<{VVI2Vdk3eAJ2?7`6m^M_kTJ0|s)J67l1=-dh-(tPYV#EW(o|EhWGV}y3E ziN|a+C3bt~F63BS)0``L3MB+f?xjMan7gnp*dvdo4=AX7tH4lQ63GK8v;AkOd$Y2< zWJ6@Kiduy&)ZC$4arPA}$lrEaJLN%%2srD+7*a?o!(~PN{pw!+PH0#x+2P`Fjy zUGMkych2qZAE$fVb4Pc^AiHX9TWhW<&wS>rC=In2__&X80RX^PQk2sI0E{I7Ky|`K z1xM^fF8TpL(pO1NTE{!{aQVR*qiz5#JUbYw+iA@lEkh#z`)1BuRfs*BGvQHhqXioA zj2Ai{Gey+nSZVy|#va06tj0%|k(zkuvAEP;#1`pzc-Zf&Q1_T0Z1HSdt=hQxt6I}5 zg#XA^a(g{gakRJRyX^NxBIox2bl1!a@meup-*4Zq<8tN`{*#MO;Cz7b$~tj`1o9J! z4f6l*8mTDY0b5EF6FkrY@|55q2=L@WoJ_o+IlpM=u$d|*54dbuy4C`7x&q=(od)DgzGp3B>+%Hhb(#oI+K&n>J48V;pC`&RZoc zOP=MqRWz=b9i%OLtsce-tax?fBg7GYG#6D7ZRh13esdNX+2>y&0#H?ISYkx$?Q)6u z-I`?G?UATtyT|@C)?a@PyxLDMk6TX>cclrq*<-mxYIPcL zEc*LVg7=j*zhQ&Ra{H~sQ4re0tTV@5lt{Uj)bb z_46|pmcgi>r|aad5;r>qM{Sqwhl>pzzo|+P_p68iGU=&juio~~sQa(qWwcVa0cG7`Z|5RHE_Ex1Od9Y~&FH>_kZ3b+sJB*KE@78xDF|B(Ifd z;D4|x|K1=L@!hCnpRtw%F<9^Qs^92yyMVO^h;yGYzdtT}q?O(Sv+Wm>67UoCqo!>J z?$$r8<;InPJq*6)#+4ja~2Wp*CQ-#@N?Heg!|>P zm*iUL@8dSF7l~F3n!TN+C-YC$T>i{xu&#RlmE*BYCvqcPtLSvQn44 zr>gWB*@u1m(6pWH=eg`LYbU->X$FVIUC(AuW zA*Z!dE8+sH3$B zA#3w$C>^P}VrO{-_rrD)Uj1~U`vuRI-A}c1ZcH&H-+-alYn9e97kh>luX8o{du)}kWSuSM+-+c7t|B_u5i3<1d~eDe z`G{iP>c@_hK6RXFz1!qz|Mgz|oF=Llt)DpR>s}?|#`R+H*p4M-Ksy_@VeERZaB)+% ztFAUo9HsMi>1~JS#r(FaI_v?@-C085S%HbCq41mg@rcpqCq2Yu_qCn?+1Q3Ib;2^Idhwf`0qE(=IHUD{{!& z-KEQoj&;2+$-P7;byIt+rj};ib1HBbuy@b2v047Tyew801asSm${CA95 z>jnJwcr5U2VYYqiPd8$@fVytm^{%hd%xxrnrqT>?UWqvOTfOaKi1(ftV97blI`+Rr zqq!Pig&#fxeUf^P*9yjz2jODOcs@Jo?E9#Mr&#TLK-{lL%_xKbz@h zC#~DgzE|Be)PL{mg0ATQw68Z?&yKIazmKZS5j25wY5Eo8-4CC}@Mj27!G4xVT>t4N z@B^3Pza;=US#Ydufen6n2s%i!>%kFq$jwx-cw+D6-CVFDjw&H4T% zsF;-<@FD$IZ>P`FOuUxQan$E7BN^heb>6@RN?v)+(;-dYV>aL#Tyo?2%%YmOhh1jt zzKN^kQR6CSJED4s8?tY_>OUc$BXKq!Qp{91tg9bz0!BNJwXmGPA!AQdY-;ac!9zS9 zmN!YOw{5FFYY!6qCt@B-ZX||W%voLT-p*E-2ym?i?i(W<6T~mTB_efHb^ibis_;uh z`zg4Cr|-$dj?bplM;QrnclTy$u$v|CiTMQ;zW7XNZ1aY6s$$MLqqc^Rn+WXvxA*)lo^r_ubbk zT*ejtlPU1cCH3XNTdF5QcN^4x2XIhpHj6V{1`s>WuA=9yr`+DVDu{cUyDm}0^N+_J z2sqZ0!=om%7{254uWhBG7k!Bm#lP{V@aqdWcoW$`>!0Qy_OgXjILLHsrD!b7!!mNcUO5f1h+7)*(T}1%Sqsh$%PR=Dq{wFKRQqL{b2)t5 zx|NK~CUA{BFt9n{*qXoTq%Y&4u=)05vl|_}cAM{oZihtZB!Ni@<4B#3A3Z$L=-+zT2?cE>N23KDkdI;l0fd&$(?Xu_-T? zH?Du>b9eINr5E-XWDyyUMxH^=H&WE0eRH4#eK#&GigqfK7UTcRmW1 zHhvwU%lC+6Yz_V*0fZKUUuV7=o<=~fD!>S@-f=UHm4kvs#cj4wyPLRQ;a7z?&4KUc zfIDz3zJ`;4*m~x=+I+r8tS+{8e=O-Y;aA-D8VS8YhwgjSM3dtZA{p@3sLFp^6yo%< z>#$+j?yTQ>R7zbJSuTJ{5t_r z&Ylz26dhcHbj2Vv*;RLK-u(cs^eb$v{z+7CBS+ts6-~<+15ML*+_OL?9Y!(&DaOfh z(9d+N;a7^n`qQl|zkY&m$R#P_Bd#S8o5b0FuEqk6#yY_HK;Wm4q(kaYX6^2$BWCxV zSQ4oL8+}hYs;z#+AJ~5N0Y*hr?79z;Q0&Mn#}m}H!25kjz=ZsE53!3I0iK@LUp}4! z-!sJ{{2u1NF7>oCpz_&xghhRoiT@-J*2@E*1}*!xt};Nj?KqUr|^Gkq7i#Ry6QH7?h6( z9;ATi|KFc9dA>F&DocZUVpL<%ZJL*W%r?#4-T%w0)%AFo zz}u27WWdLT%l*d)_&VeS6tit!0ze|YJY~>Ez($y#IwDsDyb|Zz|K&>ixM^AN%1q+# z_`kW#kQ4^CR0*Ecw);5vQT~6L+W#66mx_QA6?z*-?B%YanAHe%6M~x1^z|(uk4PDj)weqDwyP(!5%se_$O2toN*g%hz zrfuPpy3Fh|KbLD-TNtg!HYpp*)VSW`Oh zweWrbI%i4bM{QceM&VK$mi^Lvf1v?VR;UW%w>UkZ_kJT4gRZymAIACwP( z?qXKrQvb=PoFN(uRB%1BDfIr3#2Kh$%C8O4plD7rRC}Mp&a;ph2^S?);JoL9w52F@ z5Xo*T%4gRj)nD$R&GwnE?{GVRx#fe_6-{D8MEgaWkn{`7^p9Li%DqRTgm#9IEpHek z?ob0IACyJVW5bav()S-LjM%SB|Aao?kOs^@lh~|l@kcs2`a5Lea$%~Rn7-XzE*Owy zr6Z8Z+n^{puo)HN6S&=Wk9*lxIHV9?_yQ7c;rKUtx55 zvkuDT`OmbilU+_eIXg{EPjeaj(B3h~+{Nn+qZOQW!5-$y8XnOQ;NcD!TgqxeQ5aI_ zz}a{H@zl6Qnj@5`R)soLZERY@u00Y>L0%Ix_sdDX1rxw$&qw2twNR*L?!;*pE&m5| z^cNOzGW5N0-TUqC;@2+`s(%cy1A+17rUgrA!G1JS#zN8j-|XMstTJGTlA?R_&k6cQ`v)2IvC zFm#r|qaDlU=fkc3ID>22_q9NQBA42Sa9t9ivPBw7B;^s!k2c?oYM+0B{q6{>hWu2L zt6iH3TN%`7-y()yE6kmo{Nc`~{SbIWS~;FymvMzDi2h;ES+|sh$$+V9-_Lm18fu!%7IgwHy#&hnc*X;irkfjP zXW2~+kn56W9X#2Fq^eZ0{(e&R35TQ4$kZyL32$<{O-5kJ(-p zCMJZz$}N;lQHYo_DVt_wrBqRV7OLVi$>{CbY>kL{yIWyW3#RYg`?U0<%SN@U#Dvb%ae7_@LNpI`gxsTw6*yI?Y1Kj z%{15S`F5`@l#6>ECEGwYsb&%zqZ%z`dnU-w74wb_?@#_2kAzBxqM5HFKbB4ivVElx z=68bSiYanbC%Z30@~PzsQ$qK4I&q^y*ybpSxg1 z57Pwgwje(}DkfjzQ-uu=i*5@|47c(Js4M*>&zVBs1zn-00EdstiuQ`0-pbb0$_Xl? zs%EjQKuhk(-RE<&+3jN^<$NX+CUMSQuMatU_yuDJ^rb0SbTV8neLoq;%Z9Ci*x-EC zhO&*4dVUSb#WLMH+h-{~1qFl|4u?nfu@8_J3#s@Cap0QHpxCR-@wMiwULmmT5(qf2 zoz5c|Mq33CzdlAk2ysl5i_(aTu;Gq=-&8p!=khjoQ68!=5doqE!i*+@TGP6sUSDH# zUI`+>1dc)X3hS>;?i_UoHKbkyT!HH6Q;xebc#1>H1dUwvZ6wcHKavNv(S|KyT7>M?z_*;zC8jGvGTAE zv-9gZUKRq@BsTA%w>uxyh>Zsp3l%VFA&>P-({#kP(`Q^l34SsVpvGV;nTv6n;TF$L z8@?hi`&QawIk{(d`T&~=^0=~AUOufcV;;r5Xcc3ai9kl`TltgMo8Ef9rcb08No+p7 z_MRB4Y&ALCh-|&ny4Iy>G~PUk;1H*OwBBIv^>KYahWZe?{A@HJgcrq!P(;@TQJv;# z`Kb&hwGUGD4f&>7KiOq+^Jq&~VMV-;D*_BW7Gs!QLrBWqebYHV88oZ+EcL!D-#N;l z!vvi~Y<2V)vvIBav!*$vqo82T2{z*Dih9sl?_Q>=y9geySBwU^jacE-M2166?<1x8hTif?gRsw>W@OY$1vTnWlrKctXKAv$5W}^wH zsHMWh`zVI}vi2h&OB8)Gh6fcHtH9j`L)06A*o!vH?|8Q*R>HMCP%h-i-1iEUS7XI$e zJRw)0#UHaCZ!j=~R=u4l#Btg38&(Gx@>q$@S+4z?*;m)TUWeQ(2Znv)l-1?&jLsoq zpe2woN*4XHXw-?F$Sn_5+IPr}*gTVJA$B1>zTc9dZt1HGaUTdt%$IwXghGL)Zf2Cf z!518}Vg}DXDCw0sOeDxT}u%o_8Zj(6OjwT3VMR+8V`BdTh zBnIk8P7N}6X^xKsES4o1F#IJ)$h+nEUJa^TrcR;*rK{wBvg-dSe{2Asn60P4Tj;|W zmQf|TZhy$h9k)3f9S);hiE2^xl5+Zak*n!B{Il;RC( zS~V@9q&MuzCEB*_P^I%UTb57g1_L^aOD60$P$9nOvU|2dF@|T#7u$g{?J@mC<+bwE z2Z#p1#O;YHV`lUQ+aTw}VC$hq72163*Q63b2hZSwIOK>!7{1!k{_IjB(lOLs1Av6x+IBbem-N0kzjHDLy zV1vkLHRvXpIC?|$xMYXH?fl3KC#I<`#AM$Ln`&N>6DujVmcq*GdiiD zMtiRxpSejYDPla+IUxD;gn0>a0(d*~tRgldTupWDcXZU&4`*2zo#DNGG4apj?CU+ z)C`OSs7Y=wfpF&oc_?i{P8A7F&{`5!@YXAopl_5Du%gmgn&6k@R6#*PZ^7cC&8bTa1($WIPxy zuBA{!+yJG{F;9~hyKpNMKjp{_&Vw&oEX zFp&&C+*0UIzN&4}iTxp^d@hZOtk&>%Jfs+8tk?ztCC7KNJGMjs^44cJ<#dbL;}T(n z3CU%HlNwpKhA0nXKwbuD(@Zt&|0#ketb-xU$)pMZ&Bd%!_Rwy?_7T<}b#oo|>~CsL zpI?A8*h$GBPQdSnF-*u8)ZXpeKFYHr4XJ2+;b8XyU6z&r6+pKrSf#O&)u1p<^tI;E zVF8EEluCvZ_=v)!SGaMVKPy%NopZG-Iu$AbOws%go~O_XvGb3!fHTf@1i$~tqVZW< zsV52hReUndI4H-;$wYwKXVhmLk>uEa8Ufjyzj;3J@PF>)`41NIf8j}rQey5l0T|C;^&s7SKzc)goPffM ze`fgKNm}!2vApuN+b|P$aujl!<2VEN2LNK@|1;nBzf}@J|MXEISypf+|68R0|K)c= ztrmMTWN1*wIW!4;=usmF>tmiZaihoj=R?VaUi6VVuR=DR*5G}lYy^@Yan86ss|8)4(S4TX%q06UVPqJR$ z5u!1Gl?Q6{@MiykUUbC;)@Xi#xEcK8F4z-FJ;c_*3)1wHXU}DFMHrslz90d(VRgB2_)Uf)q8=RqtWKz<_c`uW{k zGeM$}_EjA*xgj9_U%`mCIbZedfq`Go{0s^p<+>qluJg(*s6Wn99V$=k)mwKGBVn+q zC2{%tntX&zri3i)&Gi;ZeTBIWm?S)Rb`HTHEYWly6g=n>lEs#qMBcu2Bz-i9*5hzU zv9~j&^5&bd--KvQ&ZwAc+)7f{dxTiqz;@)(}c_Z-s9qs?JY8tQxZuI6SJ+0EMeu*tnSLNJ97|i{D1F z?A-vjMBn#e>K^q>{&z6i@mDd^amkghFO&1>(Rvh0R9EXfH+>>4ELW@Wzyt-=M9vrf zoBMHR729B~`c{w*sz$90xg6Pe;t)13HG@feZJBNkj?ukiosVowu%SeFh7Tl=SUWcq z#BrsJ()vx7jb#AN1Y64Mx|N#;pBvWfeS~8;KdK-@^!xl_nC{oQAO4RXS_ISQ;R=AF zCE{l2`~_<53;1Nm+tUxwV28s;&hQspRBRe4pF@d20pdJ}3+{SiqFL;1Sm~W-W>hlP zULss7d@6k&xbt&zvBSSsP1ab4pBfb3hbE3B{ToahDq7nQJX{U0w>=56R>&r0ISBf) z=Ywo-u#zyqcji8u=0U9hI(biTF6pQ%-^-&#;09gTNv1?yCAq`1**X9qesZMzfx)Wt``Adg^&RR9W9&S@Ibc>LflE8!d*7_w?vso`BK1RUXRjWxuJi zv#52i=eAWmfzKF{p3oIt@CBNnT2MS_7q>$31k79F|Db=iKNw0c%kVRM=7rVsYbw06 z?6!Bfm4-DL%OW#WK&phq1~WN7UxZ?3O@i0&?ZiOGj_Efrqb9=5{QMA;FbyCDAs(8} zZ>Vx#JP3rw`dUgl!L^+&%u{G693YJGMEuM9dljiMAyRPwBR-QtG!Zc%v z=b7&OiVP_x3{(&GR^ZGEoC>EwP;I24efmdK0NcJWi~pKRYGhVgrfgLlmk%sgpdN5g+gXU8`TLL5yTz9Eoxlfkf;(Zp0-oQ$(7Q*WQ&AqVvMUsgLm z9jbjR2SjP6qD6?xO&a}0xRR;cd+Q1 z_`#Xnm8ib+^W2Wt)l!&DQe)5w;V~F=!TD&u(Z>XU9)*AKt>0j5Bt|oU%oWjS6N7_6 zTwD0Vh!Mb+!Ns&s2LZHrcnCl!=fvi{AY2fDsR$04$Eu!zVYmxaL+~HE`8Akn5Q;jc z27!DVO5f0*hk%w+(hL<^NFl30tviMefoiv?y&`pJ-a?~K41o21PN+I2Az!ti#`=hA zS$TVWPEFWe{}7wVcN}zdR_1%C*p|Qhxg~^doS`q8rDV`KYt;-;dL7?$pmoxV*ci<) zu72bS&8DQ4+24>>G-*j9m(UD8tE5Ib_HfgqY6B{^DD0SH&74kd|FHXjzk+zGvX?_ zYO#jq2_bVF>VNu4{&P=4q~%k-&NS_cqEbp)d<&i@%*Y=M z&~j$+^LeiSGk$O}ufkEdDlrqATJ-LoxjgXDz6)HY0i9FP0;0>Vw!A=-B zeTvskWE~bFq&ypWj_>w6SqFO*KT7ZaG^3>h$^ViG%v{oo)ZOkf4p*C}_9nemL;>R% zK7`>7U2+m?y_}5Y=Tvr17Bxij(9w}Bk;Gq^!lL8?7L!*zeJC} z6MkKGs?BwpLA%`jTAiM5vUcF4NKr8RhP1+$jZE@~z&ZG46&ExlzTJ`GNO(SUyvw0O z=TZLR+t7DqFeNm4(mN;x^r+XPMn;I+e5d#mEk~gmI@;p^rzGwr--eSK$Wey|foOfw z1#bo}l#U9>LwRkBXNFceO{58;i$2Jce%Lk8Ge?RaPw9|7VE_*pN(Y!#$eFkv3#+Y` z68sf(l6VUk6|xcs{e^k*VslcMv|l6RPjD!W$C#(iqvGB(9PSR8RsYaVe%!aWy6|MZUUmW zwhp-zW-;{K2)0~x7_IxGhomV+afi(o8kex2(4KtDoB9c|M|mc0zwIBJ z3D6n(n_uKh0-)t-WV{Kw>Ln}rMm6@)u$@GzJFl=_3!9b3Fdq*D%_tNCl+j{Y{3tpJ zolPy#dafMh9`3xa-9*i7^`6`Cs3E=@VuJBV=B})A14EBVR^HJ#@Euat{Xx+lWYCL~@OnwHj{7fXuQ^!{j?jKn5I_%JL{KXen4lwW`@O z<#qFdmcmpu&@4e<7pmsG)GCjP8kqyOON0shxnA}cKg0-{VE4H)KU`vQNdAk3%VeC5 zcm>SpxQxVBXD+=?>Jsx|Tk2|zd0uP#2xA;*u zZ52}TF~KGh3&v2EPI}P8G}b=l7HkJM*+TmxIxg6yqqR_L-Cd?fh0P6C%#|ZS#_Q=% z(>FhT@o+7WZBu|0gp2Ew4Zx4=L<%B%{MxpDY}OgKDm5RE_x1FXk~#x4URzyuw(CnL za4{zsH;)!&zLP|ZEPkh|0bQw$SQQRn#FB%m`gcI-G-nIxyQArW6?qrUonZjZ;O^y; zo{-Sef8#4g&_?qoJ>)B3Uq+EK-jI%be)QTOH;5VRI{_W#-%U{w%GB7nAp4M+ue!RF z-Q`Um&$NOKNiY<%@Bk6u75 zn!g)_KxE{?kF)U6*{R|@0;}DD z--#(mK_k~QkC6iuQ(MvE%JPk-`HJ z+w(4LjY92WNw*Y@S$S=F!^o65>(C8pgMmLctzcjSJDue8ubxE!A>UXtOA1~mjV2<4 z+fy)H_0bXq@gb$DXLGIPctaCayz(7)mnZ<_s%2`?A0ROXpjS)<1cl=A2{@?4VbW4Z z^W!|IwxS=$&BGlT2+?qbn@=Q!5=bFPY#z^pu+O_%U+e`cXVGl9MIx4kqTo;9Y)qj8 zEeY~*XcDVsF6Pl=B00h*K8bv9_E7N>)S$t0V6^#AcnZPPdn&*ueD4VFz2o2*q5t$Q z^r$~1R=She7#D3P60{iQlth*7mGr7qViQO*;js^FEu&;QrTNrUZ|o7nA#m-Ud2%z2#M9kvkBOKE=3M1 z+!dehr|z0d`(TvZXCHV_kQ3uoeJb_NMBuurE{7Ei5iUvuQu->}MO~U8FHpjZ0=qm6JZt z=sU=AyTro;OH39?zCL9N@6ah&H1{DlPZSE{cH=WU zz;-7!Zv+QFg)Ub68Y(G3M9NI~+=p<_@FzzZJ(`A$X1GvBUx;9)>>CdJXK^p-z%2Qj z(uuqB$w!p;8mW|Dx0mxufCg>xES` zWP?%ATSfqdT`Sch{%+Ye4Qt#QF?+}qB6!I*JO9SbcHUp<=WxJ86)E_fWcm^ssc5kP z2ih$c$BsSI%Zt7UJhaHvWrzl4ZI9f(irZpXG7#`9qB#CF$N`8~O}KtPIKt#3&KrLUqVGaABm}oC zgkke~f8fFIQgu9ADk3xSKU|{OoeI~ZfB0FA+WIqSjF{@0%%;;XJOprB6yVvnEoBhr zi-J4=(1y~lN-)6+J2m6f%PiwO&Fcs-R6!x{dPIy6*x0OeCni7IR;O5emuECAuo%9bWW?t}syCSy^u%%hv* zxewyCQq^AlTyd%nu$>G%8pJj!)sVejw(9tSTAGX{Esu%3Mvg0EWC-rM!+^?ON>RVH zmzOElk6o1U*!Dt4`6LHq+q3vBQdI}J7Gx|nEgijx#!_hu>Lv#|&PJv$A$+zrnV{!k zTSwUMsMcfQaoI5m8JmP5o1ZvBIgYm408Nh`tU)Mgr&7lITK%Al6OK4@wcxcgy4dpd z_#i(`12Q~+O?HHB2;T#HnyGY?I*%7mq?Af+j-Ke173zqsWq7RvF+F4&->3+HaSXq~ z!iKW?y9bVl<~fBxaE#oTgu~eFx2JbTEj^E#!Y53YvT7TT!Jn5txS=DczpVp-1YY*0N{Ck5^z-H79ruJx z={@e9%sq_|&=nPe=m-@?Y_pkSYei8Mx3;Q?crB^kaFcmgR#Kr&#Si;62af}Ik=`|y z|FpX@Id4wcD=pl8Jd8QXCJPl=Yy=p<;LYFGvyf)di7pJbE};Og!IULFyt@ZX5RF7S z^zTSjXnvyh6jL00kq~>b4&I}kPhid@Vnf86_2iX^EX5&-Wi1vaRn`ir71?Ky7Xy4SS_F^wk!gT zGBWy~4rj3R^f$23#H1Aw0>MZSUzPvA+r_~%|J7dz@Ukv)uaMjjg7`>z#%Zofzs?JZ zSjHd!4WAfp1X1?>Bjjt6z`+7sP+^pGvQQAQH-JFzL*|HE*==3wyTS>$wErC~-~SI5 k177<7zRjF>cNKxrG1gi95`!%Ud=j7}uO?R^WB&es0KV