Files
CydandClaude Opus 4.8 270abfc5ad Docs: reflect net48; convert tools projects to net48
Documentation now describes the .NET Framework 4.8 stack (was .NET 8):
- README build prerequisites + framework-dependent/no-runtime-install note,
  test count 136 -> 241.
- PLAN.md architecture diagram, stack decision (with PolySharp/shims note),
  suite total 136 -> 241.
- deploy/README-DEPLOY.txt: app is net48 framework-dependent (4.8 is in-box on
  Win10/11), and build prerequisites.

Also migrate the three tools (RioJoySmokeTest, RioSerialMonitor, XcfRegionExtract)
to net48 so they keep building against the now-net48 RioJoy.Core (a net8 project
cannot reference a net48 one). Added PolySharp + System.Memory/System.Text.Json
shims and replaced net-core-only APIs (string.Contains/IndexOf with comparison,
Array.Fill, generic Enum.IsDefined, int.TryParse(span)). All three build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:11:25 -05:00

134 lines
5.2 KiB
C#

using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
// Extracts the cockpit label cells from the GIMP source art (riojoy.xcf) into a
// regions.json in OverlayTemplate shape: each named text layer becomes a region
// (Name = layer name, geometry = layer box, font/size from the gimp-text-layer
// parasite). Replaces eyeballing coordinates out of GIMP by hand.
//
// dotnet run --project tools/XcfRegionExtract [-- <in.xcf> <out.json>]
//
// Defaults assume it is run from the repo root. XCF is big-endian; pointers are
// 32-bit for versions < 11. Only layer metadata is read (no tile/pixel data), so
// tile compression does not matter.
string inPath = args.Length > 0 ? args[0] : "docs/reference/customBackground/riojoy.xcf";
string outPath = args.Length > 1 ? args[1] : "docs/reference/customBackground/regions.json";
// Base cockpit image (the wallpaper background), a sibling of regions.json.
string baseImage = args.Length > 2 ? args[2] : "riojoy.png";
byte[] d = File.ReadAllBytes(inPath);
int pos = 0;
uint U32() { uint v = (uint)((d[pos] << 24) | (d[pos + 1] << 16) | (d[pos + 2] << 8) | d[pos + 3]); pos += 4; return v; }
int I32() => (int)U32();
ulong U64() { ulong v = 0; for (int i = 0; i < 8; i++) v = (v << 8) | d[pos++]; return v; }
string magic = Encoding.ASCII.GetString(d, 0, 9); // "gimp xcf "
string ver = Encoding.ASCII.GetString(d, 9, 4); // "file" (=v0) or "vNNN"
if (magic != "gimp xcf ") { Console.Error.WriteLine($"Not an XCF file: '{magic}'"); return 1; }
bool ptr64 = ver[0] == 'v' && int.TryParse(ver.Substring(1, 3), out int vn) && vn >= 11;
long Ptr() => ptr64 ? (long)U64() : U32();
pos = 14;
int width = (int)U32(), height = (int)U32();
_ = U32(); // base type
Console.WriteLine($"xcf ver='{ver}' canvas={width}x{height}");
string XcfString()
{
uint len = U32();
if (len == 0) return "";
string s = Encoding.UTF8.GetString(d, pos, (int)len - 1); // length includes trailing NUL
pos += (int)len;
return s;
}
void SkipProps()
{
while (true) { uint t = U32(); uint l = U32(); if (t == 0) break; pos += (int)l; }
}
pos = 26; // header(14) + width(4) + height(4) + base(4)
SkipProps(); // image property list
var layerPtrs = new List<long>();
while (true) { long p = Ptr(); if (p == 0) break; layerPtrs.Add(p); }
var fontRx = new Regex("\\(font \"([^\"]*)\"\\)");
var sizeRx = new Regex("\\(font-size ([0-9.]+)");
var colorRx = new Regex("\\(color \\(color-rgb ([0-9.]+) ([0-9.]+) ([0-9.]+)\\)\\)");
static string? ToHex(Match m)
{
if (!m.Success) return null;
int Byte(int g) => (int)Math.Round(double.Parse(m.Groups[g].Value, CultureInfo.InvariantCulture) * 255);
return $"#{Byte(1):X2}{Byte(2):X2}{Byte(3):X2}";
}
// Cockpit-specific orientation: these cells' labels read 90° counter-clockwise
// (the vertical button column b-10..b-1F and the center title a-00). GIMP doesn't
// record a simple text-layer rotation, so it is applied here by name.
var rotateCcw90 = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "a-00" };
for (int a = 0x10; a <= 0x1F; a++) rotateCcw90.Add($"b-{a:X2}");
var regions = new List<object>();
foreach (long lp in layerPtrs)
{
pos = (int)lp;
uint lw = U32(), lh = U32(); _ = U32(); // width, height, type
string name = XcfString();
int dx = 0, dy = 0; string? parasite = null;
while (true)
{
uint t = U32(); uint l = U32();
if (t == 0) break;
int next = pos + (int)l;
if (t == 15) { dx = I32(); dy = I32(); } // PROP_OFFSETS
else if (t == 21) // PROP_PARASITES
{
while (pos < next)
{
string pname = XcfString();
_ = U32(); // flags
uint psize = U32();
string pdata = Encoding.UTF8.GetString(d, pos, (int)psize); pos += (int)psize;
if (pname == "gimp-text-layer") parasite = pdata;
}
}
pos = next;
}
if (parasite is null) continue; // only text layers are label cells
string font = fontRx.Match(parasite) is { Success: true } fm ? fm.Groups[1].Value : "Sans";
string sizeStr = sizeRx.Match(parasite) is { Success: true } sm ? sm.Groups[1].Value : "12";
double sizePx = double.TryParse(sizeStr, NumberStyles.Float, CultureInfo.InvariantCulture, out double sp) ? sp : 12;
string? colorHex = ToHex(colorRx.Match(parasite));
regions.Add(new
{
Name = name,
X = dx,
Y = dy,
Width = (int)lw,
Height = (int)lh,
FontFamily = string.IsNullOrEmpty(font) ? "Sans" : font,
FontSizePx = sizePx,
ColorHex = colorHex,
RotationDegrees = rotateCcw90.Contains(name) ? 90.0 : (double?)null,
});
}
var template = new { BaseImagePath = baseImage, Width = width, Height = height, Regions = regions };
var opts = new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
File.WriteAllText(outPath, JsonSerializer.Serialize(template, opts));
Console.WriteLine($"Wrote {regions.Count} regions -> {outPath}");
return 0;