using System.Globalization; using System.Text.RegularExpressions; using AcDream.App.Rendering; namespace AcDream.App.Tests.Rendering.Walk; /// /// S4-c1 fix round 1, F3: parser for the OH alpha/depth captures /// (docs/research/2026-09-01-overhaul/oh-capture/*.alphadepth.log — /// tools/walk-oracle/oh/oh-capture-alpha-depth.cdb.template documents the /// line formats). Reads the two lines this round's gate cares about: /// /// /// PM poly=<ptr> mode=<0|1> counterBefore=<hex> — /// one per D3DPolyRender::DrawPortalPolyInternal @0x0059bc90 ENTRY /// (mode 1 = far-Z building punch, mode 0 = true-depth exit seal). The /// breakpoint is at function ENTRY, before the boundary guard runs inside /// the function — a PM line is emitted for every ATTEMPT, guard-rejected /// or not; counterBefore is the persistent /// portalsDrawnCount global sampled before THIS call's own possible /// increment. /// PC ov=<n> counter=<hex> fc=<0|1> — one per /// PView::DrawCells @0x005a4840 ENTRY (root turn AND every building /// look-in's own re-entrant call alike — the breakpoint doesn't /// distinguish). counter is portalsDrawnCount sampled at /// THIS call's entry, before its own possible read-then-zero. /// /// /// Frame delimiting mirrors exactly (same /// F <n> marker, same "events between F_n and F_(n+1) belong to /// frame n" rule, same final-frame drop) so a caller can parse the SAME /// capture file with both parsers and get position-consistent frame /// numbering — for the pose, this type for /// the PM/PC sequences. /// public static class WalkAlphaDepthTrace { public static IReadOnlyList Parse(IEnumerable lines) { var frames = new List(); List<(int Mode, int CounterBefore)>? currentPm = null; List<(int Ov, int Counter, int ForceClear)>? currentPc = null; List<(bool IsClip, bool IsNew)>? currentAm = null; List<(uint ThreshBits, string Ret, int AmCountAtThisPoint)>? currentFl = null; int currentNumber = 0; foreach (string line in lines) { Match frameMatch = FramePattern.Match(line); if (frameMatch.Success) { if (currentPm is not null) { frames.Add(new WalkAlphaDepthFrame( currentNumber, currentPm, currentPc!, currentAm!, currentFl!)); } currentNumber = int.Parse( frameMatch.Groups[1].Value, CultureInfo.InvariantCulture); currentPm = new List<(int, int)>(); currentPc = new List<(int, int, int)>(); currentAm = new List<(bool, bool)>(); currentFl = new List<(uint, string, int)>(); continue; } if (currentPm is null) continue; Match pm = PmPattern.Match(line); if (pm.Success) { currentPm.Add(( int.Parse(pm.Groups[1].Value, CultureInfo.InvariantCulture), int.Parse(pm.Groups[2].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture))); continue; } Match pc = PcPattern.Match(line); if (pc.Success) { currentPc!.Add(( int.Parse(pc.Groups[1].Value, CultureInfo.InvariantCulture), int.Parse(pc.Groups[2].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture), int.Parse(pc.Groups[3].Value, CultureInfo.InvariantCulture))); continue; } // S4-c2: AM mesh= surf= csurf= new=<0|1> clip=<0|1> // listSel=<0|1> — D3DPolyRender::AddMeshToAlphaList @0x0059c230's // own printf. listSel nonzero selects CLIP // (alphaedMeshCountClip/List); zero selects ALPHA // (alphaedMeshCountAlpha/List) — Ghidra-verified 2026-09-04, see // RetailAlphaList's own doc comment. "new" is the per-entry // first-for-list flag (param_4); "clip" (param_5, unused here) // is overrideClipmap. Match am = AmPattern.Match(line); if (am.Success) { currentAm!.Add(( IsClip: am.Groups["listSel"].Value != "0", IsNew: am.Groups["new"].Value != "0")); continue; } // S4-c2: FL thresh= ret= — // D3DPolyRender::FlushAlphaList @0x0059d2e0's own printf. `ret` // is the CALLER's return address (the call instruction's own // address + 5, x86 CALL rel32 being 5 bytes) — NOT the call // instruction's address itself. Match fl = FlPattern.Match(line); if (fl.Success) { currentFl!.Add(( uint.Parse( fl.Groups["thresh"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture), fl.Groups["ret"].Value, currentAm!.Count)); continue; } // Anything else (PM/PC/FL/AM already handled; cdb chrome) is out // of this parser's scope — ignored, matching WalkOracleTrace's // own catch-all. } // The last STARTED frame is deliberately never appended — the // truncated detach frame, same rule as WalkOracleTrace. return frames; } /// /// S4-c2 gate G-c2: reconstructs retail's own sequence of ACTUAL flush /// drains (site, threshold, drained-CLIP-count, drained-ALPHA-count) /// purely from the capture's AM/FL text — no execution needed on this /// side. Mirrors D3DPolyRender::FlushAlphaList's exact no-op rule /// (Ghidra-verified 2026-09-04: no-op only when BOTH running counts are /// strictly below threshold * 3000) so a threshold that DOESN'T /// drain (the overwhelming majority of 0.75f valve calls at ordinary /// scene complexity) leaves the running counts untouched for the NEXT /// flush call to inherit — exactly retail's own accumulation behavior. /// internal static IReadOnlyList<(RetailAlphaFlushSite Site, float Threshold, int DrainedClip, int DrainedAlpha)> BuildExpectedFlushTranscript(WalkAlphaDepthFrame frame) { var result = new List<(RetailAlphaFlushSite, float, int, int)>(); int runningClip = 0; int runningAlpha = 0; int lastAmCount = 0; foreach ((uint threshBits, string ret, int amCountAtThisPoint) in frame.FlEvents) { // AM lines recorded (in interleaved parse order) between the // previous FL and this one accumulate onto the running counts — // exactly retail's own accumulation, since a no-op FlushAlphaList // leaves both counters untouched for the next call to inherit. for (int i = lastAmCount; i < amCountAtThisPoint; i++) { if (frame.AmEvents[i].IsClip) runningClip++; else runningAlpha++; } lastAmCount = amCountAtThisPoint; float threshold = BitConverter.UInt32BitsToSingle(threshBits); bool noOp = runningClip < threshold * 3000f && runningAlpha < threshold * 3000f; if (!noOp) { result.Add((MapSite(ret), threshold, runningClip, runningAlpha)); runningClip = 0; runningAlpha = 0; } } return result; } private static RetailAlphaFlushSite MapSite(string ret) => ret switch { "0059f310" => RetailAlphaFlushSite.DrawBuilding, "005a1a0c" => RetailAlphaFlushSite.SortCellExit, "005a4877" => RetailAlphaFlushSite.LandscapeFlush, "00453b90" => RetailAlphaFlushSite.RenderNormalMode, _ => throw new InvalidOperationException( $"FL return address 0x{ret} is not one of the four normal-world flush sites " + "(OH1 contract §7) — the fifth private CreatureMode::Render caller, or an " + "unexpected address, reached this capture."), }; public static IReadOnlyList Load(string root, string fixtureName) { return Parse(File.ReadLines(ResolvePath(root, fixtureName))); } /// S4-c1 fix round 2 (R2-2): retail's portalsDrawnCount /// (wo(008719b4)) is a PERSISTENT session global carried by the running /// client across every captured frame, so each capture's own FIRST PM or /// PC line — wherever it falls, including the "F 1" preamble text a cdb /// session prints before the first parsed frame marker (terrace-edge and /// holtburg-doorway-still both have their first sample there) — already /// carries the value a real session would have accumulated before the /// capture began. Scans 's raw lines, /// ignoring frame boundaries entirely, for the first line matching /// either or and returns /// its counter field. public static int LoadInitialCounter(string root, string fixtureName) { foreach (string line in File.ReadLines(ResolvePath(root, fixtureName))) { Match pm = PmPattern.Match(line); if (pm.Success) return int.Parse(pm.Groups[2].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture); Match pc = PcPattern.Match(line); if (pc.Success) return int.Parse(pc.Groups[2].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture); } throw new InvalidOperationException( $"{fixtureName}: no PM/PC line found to seed PortalsDrawnCount from."); } private static string ResolvePath(string root, string fixtureName) { string repoRoot = FindRepositoryRoot(); return Path.Combine(repoRoot, Path.Combine(root.Split('/')), fixtureName + ".log"); } private static string FindRepositoryRoot() { DirectoryInfo? dir = new(AppContext.BaseDirectory); while (dir is not null) { if (File.Exists(Path.Combine(dir.FullName, "AcDream.slnx"))) return dir.FullName; dir = dir.Parent; } throw new InvalidOperationException( "AcDream.slnx not found above the test base directory; walk-oracle fixtures unavailable."); } private static readonly Regex FramePattern = new(@"^F (\d+)\s*$", RegexOptions.Compiled); private static readonly Regex PmPattern = new( @"^PM poly=[0-9a-f]+ mode=([01]) counterBefore=([0-9a-f]{4})\s*$", RegexOptions.Compiled); private static readonly Regex PcPattern = new( @"^PC ov=(\d+) counter=([0-9a-f]{4}) fc=([01])\s*$", RegexOptions.Compiled); private static readonly Regex AmPattern = new( @"^AM mesh=[0-9a-f]+ surf=-?\d+ csurf=[0-9a-f]+ new=(?[01]) clip=(?[01]) " + @"listSel=(?[01])\s*$", RegexOptions.Compiled); private static readonly Regex FlPattern = new( @"^FL thresh=(?[0-9a-f]{8}) ret=(?[0-9a-f]{8})\s*$", RegexOptions.Compiled); } /// One capture frame's PM (mode, counterBefore), PC (ov, counter, /// forceClear), AM (isClip, isNew), and FL (thresholdBits, returnAddress, /// cumulativeAmCountAtThisPoint) sequences, in the exact order the retail /// trace recorded them. Pointers/surface indices are intentionally not /// carried — every S4 gate's own comparison ignores them. public sealed record WalkAlphaDepthFrame( int Number, IReadOnlyList<(int Mode, int CounterBefore)> PmEvents, IReadOnlyList<(int Ov, int Counter, int ForceClear)> PcEvents, IReadOnlyList<(bool IsClip, bool IsNew)> AmEvents, IReadOnlyList<(uint ThreshBits, string Ret, int AmCountAtThisPoint)> FlEvents);