From ff29787f12ac278c37bc920e36ee207c5556a1ac Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 22:50:17 +0200 Subject: [PATCH] fix(D.2b): vitals from the real stacked-window LayoutDesc (0x2100006C) The vitals bars were rendered from the WRONG layout. The ids in vitals.xml (0x0600113x) belong to LayoutDesc 0x21000014 -- the 800x28 floaty side-vitals ROW. The stacked vitals window the user sees is LayoutDesc 0x2100006C (160x58), which uses a different sprite set and geometry. Dumped the real tree (new dump-vitals-layout CLI, reflective) and ported it: - Sprites (#2): the stacked-window set 0x0600747E-0x0600748F (health/stamina/ mana, each back+front 3-slice; caps 10px, mid 130px). - Right cap (#1) + fill model: retail UIElement_Meter::DrawChildren draws the back 3-slice full then the front 3-slice CLIPPED to the fill fraction (its own right-cap shows at 100%, the back's shows through when partial). UiMeter now clips the front per-slice (UV-crop) instead of growing a capless slice. - Spacing (#5): three flush 150x16 bars at y=5/21/37 in a 160x58 window (16px pitch, zero gap), per the dat rects -- not the old 20px-apart guess. - Border (#3): the window is the 8-piece chrome frame (corners 0x060074C3-C6, edges 0x060074BF-C2, 5px) -- dat-confirmed identical to RetailChromeSprites. The headless render-vitals-mockup now composites this exact window (0x2100006C) from the real sprites with the same clipped-fill model, so the look was verified before launch. Font (#4, dat Font 0x40000000) is the next commit. Decomp refs: gmVitalsUI::PostInit @0x4bfce0; UIElement_Meter::DrawChildren @0x46fbd0 (scissor-fill); geometry from LayoutDesc 0x2100006C. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiMeter.cs | 61 ++++++----- src/AcDream.App/UI/assets/vitals.xml | 19 ++-- src/AcDream.Cli/Program.cs | 12 +++ src/AcDream.Cli/VitalsLayoutDump.cs | 152 +++++++++++++++++++++++++++ src/AcDream.Cli/VitalsMockup.cs | 138 ++++++++++++++---------- 5 files changed, 293 insertions(+), 89 deletions(-) create mode 100644 src/AcDream.Cli/VitalsLayoutDump.cs diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index de97aff4..5baec4a7 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -66,11 +66,14 @@ public sealed class UiMeter : UiElement if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0)) { - // Empty track: full-width 3-slice (left-cap + stretched gradient + right-cap). - DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, 0, 0, Width, Height, withRightCap: true); - // Coloured fill: grows from the left to the value, no right-cap of its own. + // Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the + // empty track, drawn full width; the FRONT 3-slice is the coloured fill, + // drawn at FULL width too but horizontally CLIPPED to the fill fraction. + // The front carries its own right-cap (shown at 100%); clipping below 100% + // removes it and reveals the back track's right-cap — retail's scissor-fill. + DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width); if (pct is not null && p > 0f) - DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, 0, 0, Width * p, Height, withRightCap: false); + DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p); } else { @@ -94,33 +97,43 @@ public sealed class UiMeter : UiElement } /// - /// Draws a horizontal 3-slice into x at - /// (,): a native-width left-cap, a stretched - /// middle, and (when ) a native-width right-cap. Caps - /// are clamped so a narrow bar never overdraws. A 0 id skips that slice. + /// Draws the full-width horizontal 3-slice (native-width left-cap, stretched + /// middle, native-width right-cap) over this meter's rect, horizontally CLIPPED + /// so nothing past (local px from the left) is drawn. + /// The back track passes clipW = Width; the front fill passes + /// clipW = Width * fraction. Clipping UV-crops each slice proportionally, + /// so the fill ends cleanly and the back's right-cap shows through when partial. + /// A 0 id skips that slice. /// - private static void DrawHBar( + private void DrawHBar( UiRenderContext ctx, Func resolve, - uint leftId, uint tileId, uint rightId, - float x, float y, float w, float h, bool withRightCap) + uint leftId, uint midId, uint rightId, float clipW) { - if (w <= 0f) return; + if (clipW <= 0f) return; + float w = Width, h = Height; var (lt, lw, _) = resolve(leftId); - var (tt, _, _) = resolve(tileId); + var (mt, _, _) = resolve(midId); var (rt, rw, _) = resolve(rightId); - float rcap = withRightCap && rt != 0 ? MathF.Min(rw, w) : 0f; - float lcap = lt != 0 ? MathF.Min(lw, w - rcap) : 0f; + float capL = lt != 0 ? MathF.Min(lw, w) : 0f; + float capR = rt != 0 ? MathF.Min(rw, w - capL) : 0f; + float midW = w - capL - capR; - if (lt != 0 && lcap > 0f) - ctx.DrawSprite(lt, x, y, lcap, h, 0, 0, 1, 1, Vector4.One); + DrawPiece(ctx, lt, 0f, capL, h, clipW); + DrawPiece(ctx, mt, capL, midW, h, clipW); + DrawPiece(ctx, rt, w - capR, capR, h, clipW); + } - float midX = x + lcap; - float midW = w - lcap - rcap; - if (tt != 0 && midW > 0f) - ctx.DrawSprite(tt, midX, y, midW, h, 0, 0, 1, 1, Vector4.One); - - if (rcap > 0f) - ctx.DrawSprite(rt, x + w - rcap, y, rcap, h, 0, 0, 1, 1, Vector4.One); + /// Draw one slice spanning local [, + /// pieceX+], UV-cropped so nothing past + /// shows. + private static void DrawPiece( + UiRenderContext ctx, uint tex, float pieceX, float pieceW, float h, float clipW) + { + if (tex == 0 || pieceW <= 0f) return; + float visibleW = MathF.Min(pieceW, clipW - pieceX); + if (visibleW <= 0f) return; + float u1 = visibleW / pieceW; // crop the texture horizontally + ctx.DrawSprite(tex, pieceX, 0f, visibleW, h, 0f, 0f, u1, 1f, Vector4.One); } } diff --git a/src/AcDream.App/UI/assets/vitals.xml b/src/AcDream.App/UI/assets/vitals.xml index ca7e665f..eb8dfcbd 100644 --- a/src/AcDream.App/UI/assets/vitals.xml +++ b/src/AcDream.App/UI/assets/vitals.xml @@ -1,8 +1,13 @@ - - - - + + + + + diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs index 1eef5eb1..0fdad988 100644 --- a/src/AcDream.Cli/Program.cs +++ b/src/AcDream.Cli/Program.cs @@ -19,6 +19,18 @@ if (args.Length >= 1 && args[0] == "dump-vitals-bars") return DumpVitalsBars(dvbDatDir); } +if (args.Length >= 1 && args[0] == "dump-vitals-layout") +{ + string? dvlDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + string? dvlLayout = args.ElementAtOrDefault(2); + if (string.IsNullOrWhiteSpace(dvlDatDir)) + { + Console.Error.WriteLine("usage: AcDream.Cli dump-vitals-layout [0xLayoutId]"); + return 2; + } + return VitalsLayoutDump.Run(dvlDatDir, dvlLayout); +} + if (args.Length >= 1 && args[0] == "render-vitals-mockup") { string? rvmDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); diff --git a/src/AcDream.Cli/VitalsLayoutDump.cs b/src/AcDream.Cli/VitalsLayoutDump.cs new file mode 100644 index 00000000..675f671b --- /dev/null +++ b/src/AcDream.Cli/VitalsLayoutDump.cs @@ -0,0 +1,152 @@ +using System.Collections; +using System.Reflection; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; + +namespace AcDream.Cli; + +/// +/// Full reflective dump of a vitals LayoutDesc element tree: every scalar +/// property (position/size/flags) of each ElementDesc + its state sprites, +/// so the real bar rects + spacing + window size can be read from the dat +/// instead of guessed. Uses reflection so it doesn't depend on knowing the +/// DatReaderWriter property names ahead of time. +/// +public static class VitalsLayoutDump +{ + public static int Run(string datDir, string? layoutIdText) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // Default to the vitals layout dump-vitals-bars found; allow override. + uint layoutId = 0x21000014u; + if (!string.IsNullOrWhiteSpace(layoutIdText)) + { + var t = layoutIdText.Trim(); + if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..]; + uint.TryParse(t, System.Globalization.NumberStyles.HexNumber, null, out layoutId); + } + + // First: scan ALL LayoutDescs that contain a vitals meter element, with root size, + // so we can tell whether 0x21000014 is the one the user sees (row vs stacked). + Console.WriteLine("=== LayoutDescs containing a vitals meter element (0x100000E6/EC/EE) ==="); + foreach (var id in dats.GetAllIdsOfType()) + { + var l = dats.Get(id); + if (l is null) continue; + if (!ContainsAny(l, 0x100000E6u, 0x100000ECu, 0x100000EEu)) continue; + Console.WriteLine($" 0x{id:X8} {RootSizeSummary(l)}"); + } + Console.WriteLine(); + + var ld = dats.Get(layoutId); + if (ld is null) { Console.Error.WriteLine($"layout 0x{layoutId:X8} not found"); return 1; } + + Console.WriteLine($"=== FULL DUMP layout 0x{layoutId:X8} ==="); + DumpScalars("LayoutDesc", ld, 0); + foreach (var kv in ld.Elements) + DumpElement(kv.Value, 1); + return 0; + } + + private static bool ContainsAny(LayoutDesc l, params uint[] ids) + { + foreach (var kv in l.Elements) + if (ElemContains(kv.Value, ids)) return true; + return false; + } + + private static bool ElemContains(ElementDesc e, uint[] ids) + { + if (Array.IndexOf(ids, e.ElementId) >= 0) return true; + foreach (var kv in e.Children) + if (ElemContains(kv.Value, ids)) return true; + return false; + } + + private static string RootSizeSummary(LayoutDesc l) + { + // Print any LayoutDesc-level scalar that looks like a size. + var sb = new System.Text.StringBuilder(); + foreach (var p in l.GetType().GetProperties()) + { + if (p.GetIndexParameters().Length > 0) continue; + if (p.Name is "Elements") continue; + object? v; try { v = p.GetValue(l); } catch { continue; } + if (v is null) continue; + if (IsScalar(v)) sb.Append($"{p.Name}={v} "); + } + return sb.ToString().Trim(); + } + + private static void DumpElement(ElementDesc e, int depth) + { + string ind = new string(' ', depth * 2); + Console.WriteLine($"{ind}element 0x{e.ElementId:X8}"); + DumpScalars(ind + " ", e, depth); + + if (e.StateDesc is not null) DumpMedia(ind + " [DirectState]", e.StateDesc); + foreach (var s in e.States) + DumpMedia($"{ind} [state {s.Key}]", s.Value); + + foreach (var c in e.Children) + DumpElement(c.Value, depth + 1); + } + + private static readonly HashSet Skip = new() { "Children", "States", "StateDesc", "Elements", "Media" }; + + private static void DumpScalars(string label, object o, int depth) + { + foreach (var (name, val) in Members(o)) + { + if (Skip.Contains(name)) continue; + if (IsScalar(val)) + Console.WriteLine($"{label} {name} = {Fmt(name, val)}"); + } + } + + private static void DumpMedia(string label, StateDesc sd) + { + foreach (var m in sd.Media) + { + var sb = new System.Text.StringBuilder(); + foreach (var (name, val) in Members(m)) + if (IsScalar(val)) sb.Append($"{name}={Fmt(name, val)} "); + Console.WriteLine($"{label} {m.GetType().Name}: {sb.ToString().Trim()}"); + } + } + + /// Enumerate public properties AND public fields (the DatReaderWriter + /// generated types expose geometry/file ids as fields, not properties). + private static IEnumerable<(string name, object val)> Members(object o) + { + var t = o.GetType(); + foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (p.GetIndexParameters().Length > 0) continue; + object? v; try { v = p.GetValue(o); } catch { continue; } + if (v is not null) yield return (p.Name, v); + } + foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.Instance)) + { + object? v; try { v = f.GetValue(o); } catch { continue; } + if (v is not null) yield return (f.Name, v); + } + } + + private static string Fmt(string name, object v) => + name.Contains("File", StringComparison.OrdinalIgnoreCase) && v is uint u ? $"0x{u:X8}" : v.ToString() ?? ""; + + private static bool IsScalar(object v) + { + var t = v.GetType(); + if (v is string) return true; + if (t.IsPrimitive || t.IsEnum) return true; + if (v is IEnumerable) return false; + // value-type structs (Rectangle/Point/etc.) — print via ToString + return t.IsValueType; + } +} diff --git a/src/AcDream.Cli/VitalsMockup.cs b/src/AcDream.Cli/VitalsMockup.cs index 9d4dbe72..b53d8f4f 100644 --- a/src/AcDream.Cli/VitalsMockup.cs +++ b/src/AcDream.Cli/VitalsMockup.cs @@ -9,76 +9,84 @@ using SixLabors.ImageSharp.Processing; namespace AcDream.Cli; /// -/// Headless PNG preview of the retail vital bars. Loads the real RenderSurface -/// sprites from the dats and composites them with the SAME horizontal 3-slice -/// logic the in-client UiMeter.DrawHBar uses (fixed-width bevelled caps + -/// a stretched gradient middle; the empty "back" track full width, the coloured -/// "front" fill grown from the left to the value). This lets the bar assembly be -/// verified by eye without launching the client + connecting to the server. -/// Bar sprite ids come from the vitals LayoutDesc (0x21000014) via dump-vitals-bars. +/// Headless PNG preview of the retail STACKED vitals window (LayoutDesc +/// 0x2100006C, 160x58), composited with the SAME model the in-client UiMeter +/// uses: an 8-piece chrome border, then three flush-stacked 150x16 bars, each +/// drawn as a BACK 3-slice (empty track, full width) + a FRONT 3-slice +/// (coloured fill) horizontally CLIPPED to the fill fraction — so the front's +/// own right-cap shows at full, and clipping reveals the back's right-cap when +/// partial (matching retail's scissor-fill). All ids are dat-verified from +/// 0x2100006C via dump-vitals-layout. /// public static class VitalsMockup { - private readonly record struct Vital( - string Name, uint BackL, uint BackT, uint BackR, uint FrontL, uint FrontT, uint FrontR); + // 8-piece chrome border (RetailChromeSprites; 5px), dat-verified in 0x2100006C. + private const uint TL = 0x060074C3, TOP = 0x060074BF, TR = 0x060074C4; + private const uint LEFT = 0x060074C0, RIGHT = 0x060074C2; + private const uint BL = 0x060074C5, BOT = 0x060074C1, BR = 0x060074C6; + private readonly record struct Vital( + string Name, float Frac, + uint BackL, uint BackM, uint BackR, uint FrontL, uint FrontM, uint FrontR); + + // Stacked-window (0x2100006C) sprite ids — NOT the floaty-row 0x0600113x set. private static readonly Vital[] Vitals = { - new("health", 0x06001141, 0x06001140, 0x0600113F, 0x06001131, 0x06001132, 0x06001133), - new("stamina", 0x06001147, 0x06001146, 0x06001145, 0x06001137, 0x06001138, 0x06001139), - new("mana", 0x06001144, 0x06001143, 0x06001142, 0x06001134, 0x06001135, 0x06001136), + new("health", 0.80f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483), + new("stamina", 0.50f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489), + new("mana", 0.65f, 0x0600748A, 0x0600748B, 0x0600748C, 0x0600748D, 0x0600748E, 0x0600748F), }; - private static readonly float[] Fills = { 1.0f, 0.6f, 0.25f }; - - private const int BarW = 200, BarH = 14, PadX = 10, PadY = 10, GapY = 10, ColGap = 16, Zoom = 3; + // Window geometry from 0x2100006C: 160x58, 5px border, bars at x=5 y=5/21/37, 150x16. + private const int WinW = 160, WinH = 58, Border = 5, BarX = 5, BarW = 150, BarH = 16; + private static readonly int[] BarY = { 5, 21, 37 }; + private const int Zoom = 5; public static int Render(string datDir, string outPath) { - if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: directory not found: {datDir}"); return 2; } + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } using var dats = new DatCollection(datDir, DatAccessType.Read); - int cols = Fills.Length; - int canvasW = PadX * 2 + cols * BarW + (cols - 1) * ColGap; - int canvasH = PadY * 2 + Vitals.Length * BarH + (Vitals.Length - 1) * GapY; + using var canvas = new Image(WinW, WinH, new Rgba32(0, 0, 0, 0)); - // Retail vitals window backdrop is a dark translucent panel; pick a neutral - // dark gray so the bevels + gradient read clearly. - using var canvas = new Image(canvasW, canvasH, new Rgba32(38, 38, 44, 255)); - - for (int vi = 0; vi < Vitals.Length; vi++) + // 8-piece chrome border. + using (var tl = Load(dats, TL)) using (var top = Load(dats, TOP)) using (var tr = Load(dats, TR)) + using (var le = Load(dats, LEFT)) using (var ri = Load(dats, RIGHT)) + using (var bl = Load(dats, BL)) using (var bo = Load(dats, BOT)) using (var br = Load(dats, BR)) { - var v = Vitals[vi]; - using var bl = Load(dats, v.BackL); - using var bt = Load(dats, v.BackT); - using var br = Load(dats, v.BackR); - using var fl = Load(dats, v.FrontL); - using var ft = Load(dats, v.FrontT); - using var fr = Load(dats, v.FrontR); - - Console.WriteLine($"{v.Name,-8} back[{bl.Width}x{bl.Height} {bt.Width}x{bt.Height} {br.Width}x{br.Height}] " + - $"front[{fl.Width}x{fl.Height} {ft.Width}x{ft.Height} {fr.Width}x{fr.Height}]"); - - int y = PadY + vi * (BarH + GapY); - for (int ci = 0; ci < Fills.Length; ci++) - { - int x = PadX + ci * (BarW + ColGap); - DrawHBar(canvas, bl, bt, br, x, y, BarW, BarH, withRightCap: true); - int fw = (int)(BarW * Fills[ci]); - if (fw > 0) - DrawHBar(canvas, fl, ft, fr, x, y, fw, BarH, withRightCap: false); - } + Blit(canvas, tl, 0, 0, Border, Border); + Blit(canvas, top, Border, 0, WinW - 2 * Border, Border); + Blit(canvas, tr, WinW - Border, 0, Border, Border); + Blit(canvas, le, 0, Border, Border, WinH - 2 * Border); + Blit(canvas, ri, WinW - Border, Border, Border, WinH - 2 * Border); + Blit(canvas, bl, 0, WinH - Border, Border, Border); + Blit(canvas, bo, Border, WinH - Border, WinW - 2 * Border, Border); + Blit(canvas, br, WinW - Border, WinH - Border, Border, Border); } - canvas.Mutate(c => c.Resize(canvasW * Zoom, canvasH * Zoom, KnownResamplers.NearestNeighbor)); + for (int i = 0; i < Vitals.Length; i++) + { + var v = Vitals[i]; + int y = BarY[i]; + using var bl_ = Load(dats, v.BackL); using var bm = Load(dats, v.BackM); using var br_ = Load(dats, v.BackR); + using var fl = Load(dats, v.FrontL); using var fm = Load(dats, v.FrontM); using var fr = Load(dats, v.FrontR); + Console.WriteLine($"{v.Name,-8} back[{bl_.Width}x{bl_.Height} {bm.Width}x{bm.Height} {br_.Width}x{br_.Height}] " + + $"front[{fl.Width}x{fl.Height} {fm.Width}x{fm.Height} {fr.Width}x{fr.Height}] frac={v.Frac}"); + // Back track: full width. + DrawHBar(canvas, bl_, bm, br_, BarX, y, BarW, BarH, clipW: BarW); + // Front fill: full 3-slice clipped to the fraction. + DrawHBar(canvas, fl, fm, fr, BarX, y, BarW, BarH, clipW: (int)MathF.Round(BarW * v.Frac)); + } + + canvas.Mutate(c => c.Resize(WinW * Zoom, WinH * Zoom, KnownResamplers.NearestNeighbor)); canvas.SaveAsPng(outPath); - Console.WriteLine($"wrote {outPath} ({canvasW * Zoom}x{canvasH * Zoom}; rows=vitals, cols=100%/60%/25%)"); + Console.WriteLine($"wrote {outPath} ({WinW * Zoom}x{WinH * Zoom}; stacked window 0x2100006C, fracs h/s/m={Vitals[0].Frac}/{Vitals[1].Frac}/{Vitals[2].Frac})"); return 0; } public static int ExportSprite(string datDir, string idText, string outPath) { - if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: directory not found: {datDir}"); return 2; } + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } uint id = ParseHex(idText); if (id == 0) { Console.Error.WriteLine($"error: bad id '{idText}'"); return 2; } using var dats = new DatCollection(datDir, DatAccessType.Read); @@ -88,20 +96,34 @@ public static class VitalsMockup return 0; } - /// Replicates UiMeter.DrawHBar: native-width left-cap, stretched middle, - /// optional native-width right-cap; caps clamped so a narrow bar never overdraws. + /// Horizontal 3-slice (native-width left-cap, stretched middle, native-width + /// right-cap) clipped so nothing past (bar-local px) draws. + /// Mirrors the in-client UiMeter: back uses clipW=full, front uses clipW=frac*width. private static void DrawHBar( - Image canvas, Image left, Image tile, Image right, - int x, int y, int w, int h, bool withRightCap) + Image canvas, Image left, Image mid, Image right, + int x, int y, int w, int h, int clipW) { - if (w <= 0) return; - int rcap = withRightCap ? Math.Min(right.Width, w) : 0; - int lcap = Math.Min(left.Width, w - rcap); + if (w <= 0 || clipW <= 0) return; + int capL = Math.Min(left.Width, w); + int capR = Math.Min(right.Width, w - capL); + int midW = w - capL - capR; + DrawClippedPiece(canvas, left, x, y, 0, capL, h, clipW); + DrawClippedPiece(canvas, mid, x, y, capL, midW, h, clipW); + DrawClippedPiece(canvas, right, x, y, w - capR, capR, h, clipW); + } - if (lcap > 0) Blit(canvas, left, x, y, lcap, h); - int midX = x + lcap, midW = w - lcap - rcap; - if (midW > 0) Blit(canvas, tile, midX, y, midW, h); - if (rcap > 0) Blit(canvas, right, x + w - rcap, y, rcap, h); + /// Draw one slice spanning bar-local [pieceLocalX, pieceLocalX+pieceW], cropped + /// horizontally so nothing past clipW shows (UV-cropping the texture proportionally). + private static void DrawClippedPiece( + Image canvas, Image src, int x, int y, int pieceLocalX, int pieceW, int h, int clipW) + { + if (pieceW <= 0) return; + int visibleW = Math.Min(pieceW, clipW - pieceLocalX); + if (visibleW <= 0) return; + int srcCropW = Math.Max(1, (int)MathF.Round(src.Width * (visibleW / (float)pieceW))); + srcCropW = Math.Min(srcCropW, src.Width); + using var piece = src.Clone(c => c.Crop(new Rectangle(0, 0, srcCropW, src.Height)).Resize(visibleW, h)); + canvas.Mutate(c => c.DrawImage(piece, new Point(x + pieceLocalX, y), 1f)); } private static void Blit(Image canvas, Image src, int x, int y, int dw, int dh)