Files
HellionChat/HellionChat/Util/ColourUtil.cs
T
JonKazama-Hellion d5c7db9f43 fix(colours): text the game colours without an alpha byte rendered invisible
The game hands out some of its configured colours as ARGB with the alpha
byte left at zero. The byte swap then moved that zero into the alpha slot
and the text drew fully transparent.

Two guards, and they belong together: forcing alpha on a colour that is
zero everywhere would turn "no colour" into opaque black, which slips
past the invisible-text fallback in ChunkUtil and paints over the colour
the renderer would otherwise inherit.
2026-08-20 07:54:19 +02:00

270 lines
10 KiB
C#
Executable File

using System.Buffers.Binary;
using System.Numerics;
namespace HellionChat.Util;
internal static class ColourUtil
{
private static (byte r, byte g, byte b) RgbaToRgbComponents(uint rgba)
{
var r = (byte)((rgba & 0xFF000000) >> 24);
var g = (byte)((rgba & 0xFF0000) >> 16);
var b = (byte)((rgba & 0xFF00) >> 8);
return (r, g, b);
}
internal static uint RgbaToAbgr(uint rgba) => BinaryPrimitives.ReverseEndianness(rgba);
internal static Vector3 RgbaToVector3(uint rgba)
{
var (r, g, b) = RgbaToRgbComponents(rgba);
return new Vector3((float)r / 255, (float)g / 255, (float)b / 255);
}
internal static uint Vector3ToRgba(Vector3 col)
{
return ComponentsToRgba(
(byte)Math.Round(col.X * 255),
(byte)Math.Round(col.Y * 255),
(byte)Math.Round(col.Z * 255)
);
}
internal static Vector4 RgbaToVector4(uint rgba)
{
var (r, g, b) = RgbaToRgbComponents(rgba);
var a = (byte)(rgba & 0xFFu);
return new Vector4(r / 255f, g / 255f, b / 255f, a / 255f);
}
internal static uint Vector4ToRgba(Vector4 col)
{
// Clamp guards against future ImGuiColorEditFlags.HDR feeding out-of-range
// components: a raw byte-cast would wrap (e.g. (byte)Math.Round(2.0f*255)=254).
// Mirrors the ApplyAlpha clamping pattern in this file.
var r = Math.Clamp(col.X, 0f, 1f);
var g = Math.Clamp(col.Y, 0f, 1f);
var b = Math.Clamp(col.Z, 0f, 1f);
var a = Math.Clamp(col.W, 0f, 1f);
return ComponentsToRgba(
(byte)Math.Round(r * 255),
(byte)Math.Round(g * 255),
(byte)Math.Round(b * 255),
(byte)Math.Round(a * 255)
);
}
internal static uint Vector4ToAbgr(Vector4 col)
{
return RgbaToAbgr(
ComponentsToRgba(
(byte)Math.Round(col.X * 255),
(byte)Math.Round(col.Y * 255),
(byte)Math.Round(col.Z * 255),
(byte)Math.Round(col.W * 255)
)
);
}
// ---------------------------------------------------------------
// Cherry-picked from ChatTwo upstream a51789b + 5b58513 (Infiziert90,
// 2026-06-12). Both guards belong together -- a51789b alone turns a zero
// colour into opaque black, which then slips past the invisible-text
// guard in ChunkUtil and paints over the inherited colour.
// TEST-MIRROR: ../../../Hellion Build test/Util/ColourUtilTests.cs
// ---------------------------------------------------------------
public static unsafe uint ArgbToRgba(uint x)
{
if (x == 0)
return 0;
// The game omits the alpha byte on some GlobalValue colours, and the
// swap below would leave alpha at 0 -- invisible text.
if (x <= 0x00FFFFFFu)
x |= 0xFF000000u;
var buf = (byte*)&x;
(buf[1], buf[2], buf[3], buf[0]) = (buf[0], buf[1], buf[2], buf[3]);
return x;
}
internal static uint ComponentsToRgba(byte red, byte green, byte blue, byte alpha = 0xFF) =>
alpha | (uint)(red << 24) | (uint)(green << 16) | (uint)(blue << 8);
internal static uint AdjustBrightness(uint abgr, float factor)
{
var a = (byte)((abgr & 0xFF000000) >> 24);
var b = (byte)((abgr & 0x00FF0000) >> 16);
var g = (byte)((abgr & 0x0000FF00) >> 8);
var r = (byte)(abgr & 0x000000FF);
var nr = (byte)Math.Clamp(r * factor, 0f, 255f);
var ng = (byte)Math.Clamp(g * factor, 0f, 255f);
var nb = (byte)Math.Clamp(b * factor, 0f, 255f);
return ((uint)a << 24) | ((uint)nb << 16) | ((uint)ng << 8) | nr;
}
// Modulates the alpha byte of an ABGR color by a factor in [0, 1].
// RGB stays intact. Used by the hover-lerp path where each
// frame produces a fractional alpha value but the colour itself
// must not shift.
internal static uint ApplyAlpha(uint abgr, float alphaFactor)
{
alphaFactor = Math.Clamp(alphaFactor, 0f, 1f);
var origAlpha = (byte)((abgr >> 24) & 0xFFu);
var newAlpha = (byte)Math.Round(origAlpha * alphaFactor);
return (abgr & 0x00FFFFFFu) | ((uint)newAlpha << 24);
}
// Mixes an ABGR colour's RGB channels toward white (0xFF) by factor t in
// [0, 1]; the alpha byte is left untouched. Hover-sheen accent tint:
// a low factor nudges the sweep toward the element's accent hue without
// going fully saturated (effect level stays "subtle"). RGB-only on
// purpose -- DrawHoverSheen owns the alpha falloff.
// TEST-MIRROR: ../../../Hellion Build test/Util/ColourUtilTintTests.cs
// Relative luminance per WCAG 2.1, 0..1. Channels are linearised first:
// sRGB is gamma-encoded, so averaging the raw bytes overstates the
// brightness of dark colours badly -- and almost every surface in this
// plugin is a dark colour.
//
// Alpha is ignored. This answers "is this light or dark", and a translucent
// light surface still reads light against what is behind it.
internal static float Luminance(uint abgr)
{
static float Linear(uint channel)
{
var c = channel / 255f;
return c <= 0.04045f ? c / 12.92f : MathF.Pow((c + 0.055f) / 1.055f, 2.4f);
}
return 0.2126f * Linear(abgr & 0xFFu)
+ 0.7152f * Linear((abgr >> 8) & 0xFFu)
+ 0.0722f * Linear((abgr >> 16) & 0xFFu);
}
// WCAG contrast ratio between two colours, 1.0 (identical) to 21.0 (black
// on white). 4.5 is the readability floor for body text, 3.0 for large text
// and for icons and other non-text marks.
internal static float ContrastRatio(uint a, uint b)
{
var la = Luminance(a);
var lb = Luminance(b);
var (hi, lo) = la > lb ? (la, lb) : (lb, la);
return (hi + 0.05f) / (lo + 0.05f);
}
// Pushes a foreground away from its background until it clears the ratio,
// moving whichever direction the background is not.
//
// This is what a fixed palette cannot do. A theme picks one text colour, but
// the same text lands on a base surface, on a lit top edge and on an accent
// fill, and a value that reads on one of those can vanish on another. White
// on pale violet was the reported case.
internal static uint EnsureContrast(uint foregroundAbgr, uint backgroundAbgr, float minRatio)
{
if (ContrastRatio(foregroundAbgr, backgroundAbgr) >= minRatio)
return foregroundAbgr;
// Direction is chosen by which end actually reaches further, not by
// whether the background counts as dark. A mid-luminance background can
// sit below 0.5 and still be far too light for white text: pale violet
// measures 0.39, so a "background is dark, brighten it" rule tried to
// make white whiter and got nowhere.
var towardWhite =
ContrastRatio(0xFFFFFFFFu, backgroundAbgr) > ContrastRatio(0xFF000000u, backgroundAbgr);
var best = foregroundAbgr;
// Sixteen steps to full white or full black. Stops at the first value
// that clears, so a colour only travels as far as it has to and keeps
// as much of its hue as the ratio allows.
for (var i = 1; i <= 16; i++)
{
var t = i / 16f;
best = towardWhite
? LerpTowardWhite(foregroundAbgr, t)
: LerpTowardBlack(foregroundAbgr, t);
if (ContrastRatio(best, backgroundAbgr) >= minRatio)
return best;
}
return best;
}
// Picks whichever of two candidates stands further from the background.
// Themes range from near-black to pastel, so a fixed text colour on an accent
// fill is legible in some and invisible in others.
internal static uint OnColour(uint background, uint light, uint dark) =>
Luminance(background) > 0.5f ? dark : light;
// Mixes an ABGR colour's RGB channels toward black by factor t, alpha
// untouched. The counterpart to LerpTowardWhite, and the reason both exist
// rather than AdjustBrightness: this plugin's surfaces sit near black, where
// a multiplier has almost nothing to scale. 12 * 1.15 is still 13.
internal static uint LerpTowardBlack(uint abgr, float t)
{
t = Math.Clamp(t, 0f, 1f);
var a = (byte)((abgr >> 24) & 0xFFu);
var b = (byte)Math.Round(((abgr >> 16) & 0xFFu) * (1f - t));
var g = (byte)Math.Round(((abgr >> 8) & 0xFFu) * (1f - t));
var r = (byte)Math.Round((abgr & 0xFFu) * (1f - t));
return ((uint)a << 24) | ((uint)b << 16) | ((uint)g << 8) | r;
}
internal static uint LerpTowardWhite(uint abgr, float t)
{
t = Math.Clamp(t, 0f, 1f);
var a = (byte)((abgr >> 24) & 0xFFu);
var b = (byte)((abgr >> 16) & 0xFFu);
var g = (byte)((abgr >> 8) & 0xFFu);
var r = (byte)(abgr & 0xFFu);
var nr = (byte)Math.Round(r + (0xFF - r) * t);
var ng = (byte)Math.Round(g + (0xFF - g) * t);
var nb = (byte)Math.Round(b + (0xFF - b) * t);
return ((uint)a << 24) | ((uint)nb << 16) | ((uint)ng << 8) | nr;
}
// Mixes two ABGR colours channel by channel, alpha included. Used where a
// widget crossfades between two theme slots rather than toward a constant.
internal static uint Lerp(uint fromAbgr, uint toAbgr, float t)
{
t = Math.Clamp(t, 0f, 1f);
uint Mix(int shift)
{
var a = (byte)((fromAbgr >> shift) & 0xFFu);
var b = (byte)((toAbgr >> shift) & 0xFFu);
return (uint)Math.Round(a + (b - a) * t) & 0xFFu;
}
return (Mix(24) << 24) | (Mix(16) << 16) | (Mix(8) << 8) | Mix(0);
}
public static uint HexToRgba(string hex)
{
ArgumentNullException.ThrowIfNull(hex);
var s = hex.StartsWith('#') ? hex[1..] : hex;
if (s.Length != 6 && s.Length != 8)
throw new FormatException(
$"Hex colour must be 6 or 8 hex digits, got {s.Length}: '{hex}'"
);
if (
!uint.TryParse(
s,
System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture,
out var value
)
)
throw new FormatException($"Hex colour '{hex}' is not a valid hexadecimal value");
if (s.Length == 6)
value = (value << 8) | 0xFFu; // RRGGBB → RRGGBBFF
return value;
}
}