Replaces the single scope-global distance-sorted RetailAlphaQueue with
retail's own two independent FIFO lists (CLIP/ALPHA, capacity 3000 each,
D3DPolyRender::AddMeshToAlphaList's exact append-only/capacity-drop
behavior — Ghidra-verified 2026-09-04), routed by a new
RetailAlphaMeshRouter porting DrawMesh's five-row immediate/delayed
branch table and ConstructMesh's subset-mask formula as pure functions,
and drained at retail's four normal-world FlushAlphaList sites
(DrawBuilding/DrawBlock/PView::DrawCells/RenderNormalMode) under the
exact Ghidra-verified no-op predicate (both counts strictly below
threshold*3000). A new WalkFrameEventKind.SortCellExit /
IWalkEventSink.OnSortCellExit / IWalkFrameLeafRenderer.FlushSortCellExit
fires once per admitted land-block cell for DrawBlock's 0.75f valve,
pinned by a dedicated far/near ordering test in RetailFrameWalkTests.cs.
WbDrawDispatcher's two submit sites and ParticleRenderer's one route
through the router; since none of the three ever draws during the Sky
leaf, installs a detail surface, or sets MultiPassAlpha, rows 1/2/4/5
are provably unreachable there and the call sites assert loudly rather
than building unexercisable immediate-draw plumbing. FlushFartherThan,
RetailAlphaOrdering.ComputeViewerDistance, and every viewerDistance
argument on the submit path are deleted.
Scope note (packet s4-depth-alpha-packet.md §10): C4 (routing EnvCell's
transparent shell batches through the shared queue) was not attempted —
EnvCell draws one per-cell MultiDrawIndexedIndirect call with no
per-subset deferred-replay abstraction, and building one without visual
verification (no graphical client in this worktree) was judged out of
this bounded chunk's scope. AP-34 is therefore retired and replaced by
two narrower rows rather than deleted outright: AP-236 (the carried-
forward EnvCell-immediate residual) and AP-237 (a newly identified gap:
TranslucencyKind.AlphaBlend can arise from either retail's Alpha/
Translucent bits, mask 0x02/ALPHA, or the Translucent+ClipMap "cloud"
override, mask 0x08/CLIP — GroupKey doesn't retain the raw bit to tell
them apart, so the router always picks ALPHA; only known example is
cloud GfxObj 0x01004C35). Both are compositing-order-only divergences,
never blend/visual ones.
Mutation checks (each applied, confirmed failing, then reverted):
- FIFO drain order reversed -> 5 RetailAlphaQueueTests fail (order).
- FlushAlphaList `<` -> `<=` -> boundary/scratch tests fail (2250 case
reads drained=0 instead of 2250).
- Capacity check loosened (3000 -> 6000) -> overflow-drop test fails
(TryAppend returns true, PendingCount reads 3001).
- IsFirstForList forced true -> flag test fails once inspected on the
pre-flush two-entry snapshot (the post-flush single-survivor version
of this test was vacuous and rewritten).
- Router row 3 condition inverted -> both the hand-traced Theory (6
cases) and the 160-cell independent-truth-table brute force fail (20
mismatches).
- SortCellExit emitted before OnLandscapeCellTurn instead of after ->
RetailFrameWalkTests ordering pin fails ("SCX must immediately follow
its own cell's SC").
- Prepare-per-list instead of prepare-once-combined -> the CLIP/ALPHA
boundary batching test throws (index out of range).
Gates: Release build 0 warnings/0 errors. Hermetic lane (Lane!=Installed
Dat&...&Status!=KnownFailure) 6855/6855 passed. InstalledDat lane 249
passed / 10 failed — exactly the 4 pre-existing failures (#383 x2
LayoutImporter, TowerAscent KnownFailure, #458 Oh_doorway_still
KnownFailure) plus 6 NEW KnownFailure Facts
(AlphaFlushTranscript_*_MatchesRetailFrame2, one per capture) extending
this gate from PM/PC to AM/FL: the flush SITE+THRESHOLD sequence matches
the capture exactly for all six poses (including zero SortCellExit
drains in every capture, confirming the 0.75 valve is inert at these
scene complexities in both retail and this replay); the drained-COUNT-
per-list dimension diverges because this hermetic harness (matching the
existing PM/PC gate's own EmptyAlphaDepthWorldData design) carries no
live GfxObj/particle content, so every observed count reads (0,0)
against retail's real per-frame volume — both sequences quoted in full
per pose in the packet's new §10. Shader classes (VulkanShaderDescriptor
ContractTests/VulkanShaderManifestTests/RenderPackSpirvValidatorTests)
32/32 passed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
258 lines
12 KiB
C#
258 lines
12 KiB
C#
using System.Globalization;
|
|
using System.Text.RegularExpressions;
|
|
using AcDream.App.Rendering;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Walk;
|
|
|
|
/// <summary>
|
|
/// 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:
|
|
///
|
|
/// <list type="bullet">
|
|
/// <item><c>PM poly=<ptr> mode=<0|1> counterBefore=<hex></c> —
|
|
/// one per <c>D3DPolyRender::DrawPortalPolyInternal</c> @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; <c>counterBefore</c> is the persistent
|
|
/// <c>portalsDrawnCount</c> global sampled before THIS call's own possible
|
|
/// increment.</item>
|
|
/// <item><c>PC ov=<n> counter=<hex> fc=<0|1></c> — one per
|
|
/// <c>PView::DrawCells</c> @0x005a4840 ENTRY (root turn AND every building
|
|
/// look-in's own re-entrant call alike — the breakpoint doesn't
|
|
/// distinguish). <c>counter</c> is <c>portalsDrawnCount</c> sampled at
|
|
/// THIS call's entry, before its own possible read-then-zero.</item>
|
|
/// </list>
|
|
///
|
|
/// Frame delimiting mirrors <see cref="WalkOracleTrace"/> exactly (same
|
|
/// <c>F <n></c> 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 — <see cref="WalkOracleTrace"/> for the pose, this type for
|
|
/// the PM/PC sequences.
|
|
/// </summary>
|
|
public static class WalkAlphaDepthTrace
|
|
{
|
|
public static IReadOnlyList<WalkAlphaDepthFrame> Parse(IEnumerable<string> lines)
|
|
{
|
|
var frames = new List<WalkAlphaDepthFrame>();
|
|
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=<ptr> surf=<n> csurf=<ptr> 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=<hex float bits> ret=<return address> —
|
|
// 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <c>D3DPolyRender::FlushAlphaList</c>'s exact no-op rule
|
|
/// (Ghidra-verified 2026-09-04: no-op only when BOTH running counts are
|
|
/// strictly below <c>threshold * 3000</c>) 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.
|
|
/// </summary>
|
|
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<WalkAlphaDepthFrame> Load(string root, string fixtureName)
|
|
{
|
|
return Parse(File.ReadLines(ResolvePath(root, fixtureName)));
|
|
}
|
|
|
|
/// <summary>S4-c1 fix round 2 (R2-2): retail's <c>portalsDrawnCount</c>
|
|
/// (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 <paramref name="fixtureName"/>'s raw lines,
|
|
/// ignoring frame boundaries entirely, for the first line matching
|
|
/// either <see cref="PmPattern"/> or <see cref="PcPattern"/> and returns
|
|
/// its counter field.</summary>
|
|
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=(?<new>[01]) clip=(?<clip>[01]) "
|
|
+ @"listSel=(?<listSel>[01])\s*$",
|
|
RegexOptions.Compiled);
|
|
private static readonly Regex FlPattern = new(
|
|
@"^FL thresh=(?<thresh>[0-9a-f]{8}) ret=(?<ret>[0-9a-f]{8})\s*$", RegexOptions.Compiled);
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
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);
|