chore(D.2b): CLI dump-vitals-bars — read vitals LayoutDesc meter sprites

Adds `AcDream.Cli dump-vitals-bars <datDir>` subcommand that:
- Scans all 101 LayoutDesc objects in client_local_English.dat
- Finds the vitals window layout (0x21000014) by locating the Health
  meter element id 0x100000E6 (from gmVitalsUI::PostInit decomp)
- Walks each meter's sub-element tree (typed access via ElementDesc.Children,
  ElementDesc.States, ElementDesc.StateDesc, StateDesc.Media, MediaDescImage.File)
- Prints every RenderSurface DataId (0x06xxxxxx) per vital

Authoritative output:
  HEALTH  (0x100000E6): front-bar fill 0x06005F3D / track fill 0x06005F3C
                        E8/E9/EA pieces: 0x06001131/32/33, 0x06001141/40/3F
  STAMINA (0x100000EC): front-bar fill 0x06005F3F / track fill 0x06005F3E
                        E8/E9/EA pieces: 0x06001137/38/39, 0x06001147/46/45
  MANA    (0x100000EE): front-bar fill 0x06005F41 / track fill 0x06005F40
                        E8/E9/EA pieces: 0x06001134/35/36, 0x06001144/43/42

LayoutDesc shape discovered: Fields Width, Height, Elements (HashTable<uint,ElementDesc>).
ElementDesc shape: ElementId, Type, BaseElement, BaseLayoutId, DefaultState,
  X/Y/Width/Height/ZLevel, LeftEdge/TopEdge/RightEdge/BottomEdge,
  States (Dictionary<UIStateId,StateDesc>), Children (Dictionary<uint,ElementDesc>),
  StateDesc (direct single state).
StateDesc shape: StateId, PassToChildren, IncorporationFlags,
  Properties (Dictionary<uint,BaseProperty>), Media (List<MediaDesc>).
MediaDescImage shape: File (uint DataId), DrawMode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-14 19:39:33 +02:00
parent b303baf4a1
commit 56ee5eff60

View file

@ -3,8 +3,21 @@ using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using Env = System.Environment;
// ─── subcommand dispatch ────────────────────────────────────────────────────
if (args.Length >= 1 && args[0] == "dump-vitals-bars")
{
string? dvbDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (string.IsNullOrWhiteSpace(dvbDatDir))
{
Console.Error.WriteLine("usage: AcDream.Cli dump-vitals-bars <dat-directory>");
return 2;
}
return DumpVitalsBars(dvbDatDir);
}
// Phase 0: open the four AC dat files and print how many of each asset type live in them.
// This proves DatReaderWriter works on our retail dats and gives us a baseline inventory
// to compare against what a future renderer needs.
@ -160,3 +173,146 @@ static (string Name, Func<int> Count)[] CountCellByLow16(DatCollection dats)
("Region", () => dats.GetAllIdsOfType<Region>().Count()),
};
}
/// <summary>
/// dump-vitals-bars: find the vitals window LayoutDesc (0x21000014) and print the
/// RenderSurface DataIds (0x06xxxxxx) used by the Health, Stamina, and Mana meter
/// bars. Each meter element (E6/EC/EE) has two child sub-groups per bar visual
/// (front-bar and back-bar/track), each containing:
/// - elem 0x100004A9 (ShowDetail state image = Alphablend fill sprite)
/// - elem 0x100000E8 (DirectStateDesc = left-edge sprite)
/// - elem 0x100000E9 (DirectStateDesc = fill-tile sprite)
/// - elem 0x100000EA (DirectStateDesc = right-edge sprite)
///
/// Based on the Sept 2013 EoR retail dat, vitals layout id = 0x21000014.
/// Element ids from gmVitalsUI::PostInit in acclient_2013_pseudo_c.txt.
/// </summary>
static int DumpVitalsBars(string dvbDatDir)
{
const uint HEALTH_ELEM_ID = 0x100000E6u;
const uint STAMINA_ELEM_ID = 0x100000ECu;
const uint MANA_ELEM_ID = 0x100000EEu;
if (!Directory.Exists(dvbDatDir))
{
Console.Error.WriteLine($"error: directory not found: {dvbDatDir}");
return 2;
}
using var dats = new DatCollection(dvbDatDir, DatAccessType.Read);
// Find the vitals layout: scan all LayoutDescs for one containing the health meter element.
Console.WriteLine("Scanning LayoutDescs for vitals window (element 0x100000E6 = Health meter)...");
uint? vitalsId = null;
LayoutDesc? vitalsLayout = null;
foreach (var id in dats.GetAllIdsOfType<LayoutDesc>())
{
var ld = dats.Get<LayoutDesc>(id);
if (ld is null) continue;
if (VbContainsElementId(ld, HEALTH_ELEM_ID)) { vitalsId = id; vitalsLayout = ld; break; }
}
if (vitalsLayout is null)
{
Console.Error.WriteLine("ERROR: no LayoutDesc contains element 0x100000E6 (Health meter).");
return 1;
}
Console.WriteLine($"Found vitals layout: 0x{vitalsId!.Value:X8}");
Console.WriteLine();
// For each vital meter, collect all MediaDescImage DataIds from its sub-tree.
var meters = new[] { (HEALTH_ELEM_ID, "HEALTH"), (STAMINA_ELEM_ID, "STAMINA"), (MANA_ELEM_ID, "MANA") };
foreach (var (eid, vitalName) in meters)
{
Console.WriteLine($"{vitalName} meter (element 0x{eid:X8}) in layout 0x{vitalsId!.Value:X8}:");
var meterElem = VbFindElement(vitalsLayout!, eid);
if (meterElem is null) { Console.WriteLine(" <element not found>"); continue; }
var sprites = new List<(string Role, uint DataId, string DrawMode)>();
VbCollectSprites(meterElem, sprites, 0);
if (sprites.Count == 0)
{
Console.WriteLine(" <no sprites found in sub-tree>");
}
else
{
foreach (var (role, dataId, drawMode) in sprites)
Console.WriteLine($" {role,-35} 0x{dataId:X8} ({drawMode})");
}
Console.WriteLine();
}
return 0;
}
// ─── dump-vitals-bars helpers ───────────────────────────────────────────────
static bool VbContainsElementId(LayoutDesc ld, uint targetId)
{
var elems = ld.Elements;
foreach (var kvp in elems)
{
if (kvp.Key == targetId) return true;
if (VbChildContains(kvp.Value, targetId)) return true;
}
return false;
}
static bool VbChildContains(ElementDesc elem, uint targetId)
{
foreach (var kvp in elem.Children)
{
if (kvp.Key == targetId) return true;
if (VbChildContains(kvp.Value, targetId)) return true;
}
return false;
}
static ElementDesc? VbFindElement(LayoutDesc ld, uint targetId)
{
foreach (var kvp in ld.Elements)
{
if (kvp.Key == targetId) return kvp.Value;
var found = VbFindChild(kvp.Value, targetId);
if (found is not null) return found;
}
return null;
}
static ElementDesc? VbFindChild(ElementDesc elem, uint targetId)
{
foreach (var kvp in elem.Children)
{
if (kvp.Key == targetId) return kvp.Value;
var found = VbFindChild(kvp.Value, targetId);
if (found is not null) return found;
}
return null;
}
static void VbCollectSprites(ElementDesc elem, List<(string, uint, string)> out_, int depth)
{
string indent = new string(' ', depth * 2);
// Check the element's direct StateDesc
if (elem.StateDesc is not null)
VbExtractMedia(elem.StateDesc, $"{indent}elem_0x{elem.ElementId:X8}.DirectState", out_);
// Check each named state
foreach (var kvp in elem.States)
VbExtractMedia(kvp.Value, $"{indent}elem_0x{elem.ElementId:X8}.{kvp.Key}", out_);
// Recurse into children
foreach (var kvp in elem.Children)
VbCollectSprites(kvp.Value, out_, depth + 1);
}
static void VbExtractMedia(StateDesc sd, string role, List<(string, uint, string)> out_)
{
foreach (var m in sd.Media)
{
if (m is MediaDescImage img && img.File != 0)
out_.Add((role, img.File, img.DrawMode.ToString()));
}
}