Ports RenderDeviceD3D::DrawBlock @0x005a17c0's real per-cell order: loop 1 (@0x005a1876) prepares shadow lists; loop 2 (@0x005a197d) DrawLandCell(cell) @0x005a19c0 fires ONLY when the cell is in view, STRICTLY BEFORE DrawSortCell(cell) @0x005a19e6, which fires whenever alwaysDrawObjects (retail default 1 @0x00820ed4) or the cell is in view. RetailFrameWalk.DrawLandscape now emits sink.OnLandCellTurn( landblockId, side, cellIndex) at that exact point, per admitted cell, before the existing DrawBuilding + OnLandscapeCellTurn (the DrawSortCell half). WalkFrameDriver records one LandCell frame event per turn and deletes the whole-stage TerrainSlice(0) emission and the DrawTerrainSlice leaf outright — drawing all terrain before every building let a nearer building's far-Z punch survive under farther terrain drawn afterward, the doorway-behind-a-hill fragment bug from the owner's G2 Holtburg screenshot; this chunk removes it by ORDER alone, with no depth-compare change (S4's punch z-func question is untouched, per the contract). Index-run arithmetic (S3 §9.1 R3): the terrain mesh is cell-major (LandblockMesh.Build: cy outer, cx inner, 6 indices/cell, 384/land- block). A retail LOD cell (side n, LOD coords X,Y) covers cx in [X*8/n,(X+1)*8/n), cy in [Y*8/n,(Y+1)*8/n) — one contiguous run per covered cy row: side 8 -> one run of 6, side 4 -> two runs of 12, side 2 -> four runs of 24; side 1's single coarse cell covers every row contiguously so its 8 per-row runs collapse into ONE run of all 384 indices. TerrainModernRenderer.AppendCellIndexRuns (pure, no GPU, no baked table) and DrawLandCellRuns (resolves the landblock's slot, builds one DrawElementsIndirectCommand per run, reuses the existing DrawRhi bind-and-submit path) implement this; TerrainModernRenderer .Draw(...) is untouched and keeps serving non-walk callers (directional- shadow receivers, the flat terrain path). Order-preserving batching (S3 §9.2 B2): WalkFrameDriver.Replay merges consecutive same-landblock LandCell events with no intervening event into ONE DrawLandCellBatch leaf call; any other event splits the batch. In production this rarely fires because retail's own DrawSortCell (AlwaysDrawObjects=true) always interposes an object- list turn between one cell's LandCell event and the next's — see perfNote in the task report for the resulting command-count increase. Deleted as dead: the _walkTerrainInViewLandcells field and SetWalkTerrainInViewLandcells setter on RetailPViewPassExecutor (fed only DrawWalkTerrainSlice's inViewLandcells filter, which no longer exists — the walk's own per-cell CellInView admission is now the sole terrain-visibility authority) and its two call sites in RetailPViewRenderer.DrawWalkDrivenStatics. Weather placement (S3 §9.1 R5): confirmed unchanged. GameSky's weather pass (RenderWeather, gated on is_player_outside) already runs after every LandCell event for both root kinds — for an outdoor root, DrawLandscapeDynamicsPhase is called directly after driver.Replay() completes (which processes the whole per-cell _events list first); for an interior root with ov>0, it fires via the LandscapeFlush leaf (RetailPViewRenderer.FlushWalkLandscape -> _walkPreClearDynamics), and OnInteriorFloodDrawTurn only emits LandscapeFlush AFTER DrawLandscape's per-cell loop has fully run and recorded every LandCell event ahead of it in the same _events list Replay walks in order. No code change needed; verified by reading the call sites. Tests: WalkEvents/RetailFrameWalk/WalkFrameDriver's existing pins updated (every "TERRAIN:0" expectation deleted, matching the deleted event); new coverage for T1 (WalkLandCellOrderTests — LandWalkOrder + WalkLandscape.CalcDrawOrder + WalkLandscapeAssembler .SideCellCountForRing reproduce both cathedral captures' frame-2 LC/SC sequences byte-for-byte, 533/698 arrival and 531/757 leak, cross- verified against the retail ring-to-LOD table), T2 (a synthetic two- block landscape with a real WalkVisibilityMath-driven out-of-view column, proving LC-before-building/statics, far-to-near, no-LC-but- keeps-SC for the excluded cells, and no TerrainSlice event of any kind), T3 (TerrainLandCellIndexRunsTests — the index-run arithmetic for every side/cell, disjoint and exhaustive over the 384-index landblock), T4 (batching merge/split), and the RetailPViewPassExecutor CompiledCallGraph pin retargeted at DrawWalkLandCellBatch / DrawLandCellRuns. WalkOracleTrace gains LC/SC event kinds and a Load(root, name) overload for the OH capture directory — the one parser change this chunk needs, no validator, no other infrastructure. App hermetic lane: 6,786/6,786 (6,765 baseline + 21 new). InstalledDat lane: 241 passed, 3 accepted failures (2 pre-existing #383 layout fixture-drift tests, 1 TowerAscent Status=KnownFailure) — unchanged from baseline. Core terrain tests: 116/116 (Core untouched by this chunk). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
234 lines
9.7 KiB
C#
234 lines
9.7 KiB
C#
using System.Globalization;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Walk;
|
|
|
|
/// <summary>
|
|
/// Parser for the FW0 retail walk-oracle traces
|
|
/// (docs/research/2026-08-30-fw-walk-oracle/ — captured live from the
|
|
/// PDB-paired 2013 retail client on 2026-08-30; format documented in that
|
|
/// directory's README). These traces are Campaign FW's conformance
|
|
/// fixtures: FW1's RetailFrameWalk must reproduce each frame's event
|
|
/// sequence position-for-position.
|
|
///
|
|
/// The final frame of every capture is dropped: the harness detaches at
|
|
/// the frame MARKER, so the last frame never records its draws and would
|
|
/// read as a false "outdoor, no landscape" frame.
|
|
/// </summary>
|
|
public static class WalkOracleTrace
|
|
{
|
|
public static IReadOnlyList<WalkOracleFrame> Parse(IEnumerable<string> lines)
|
|
{
|
|
var frames = new List<WalkOracleFrame>();
|
|
List<WalkOracleEvent>? current = null;
|
|
int currentNumber = 0;
|
|
WalkOraclePose? currentPose = null;
|
|
|
|
foreach (string line in lines)
|
|
{
|
|
Match frameMatch = FramePattern.Match(line);
|
|
if (frameMatch.Success)
|
|
{
|
|
if (current is not null)
|
|
frames.Add(new WalkOracleFrame(currentNumber, current, currentPose));
|
|
currentNumber = int.Parse(
|
|
frameMatch.Groups[1].Value, CultureInfo.InvariantCulture);
|
|
current = new List<WalkOracleEvent>();
|
|
currentPose = null;
|
|
continue;
|
|
}
|
|
if (current is null)
|
|
continue;
|
|
|
|
Match poseMatch = PosePattern.Match(line);
|
|
if (poseMatch.Success)
|
|
{
|
|
// P <objcell_id> <origin xyz> <quat q0..q3> — raw IEEE-754 dwords
|
|
// dumped from Render::viewer_pos @0x0081ef00 at the frame marker.
|
|
uint[] raw = new uint[8];
|
|
for (int i = 0; i < 8; i++)
|
|
raw[i] = uint.Parse(
|
|
poseMatch.Groups[i + 1].Value,
|
|
NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
|
currentPose = new WalkOraclePose(
|
|
raw[0],
|
|
new System.Numerics.Vector3(
|
|
BitConverter.Int32BitsToSingle((int)raw[1]),
|
|
BitConverter.Int32BitsToSingle((int)raw[2]),
|
|
BitConverter.Int32BitsToSingle((int)raw[3])),
|
|
BitConverter.Int32BitsToSingle((int)raw[4]),
|
|
BitConverter.Int32BitsToSingle((int)raw[5]),
|
|
BitConverter.Int32BitsToSingle((int)raw[6]),
|
|
BitConverter.Int32BitsToSingle((int)raw[7]));
|
|
continue;
|
|
}
|
|
|
|
if (line == "LS")
|
|
{
|
|
current.Add(WalkOracleEvent.Landscape());
|
|
continue;
|
|
}
|
|
Match buildingMatch = BuildingPattern.Match(line);
|
|
if (buildingMatch.Success)
|
|
{
|
|
current.Add(WalkOracleEvent.Building(ParseId(buildingMatch.Groups[1].Value)));
|
|
continue;
|
|
}
|
|
Match insideMatch = DrawInsidePattern.Match(line);
|
|
if (insideMatch.Success)
|
|
{
|
|
current.Add(WalkOracleEvent.DrawInside(ParseId(insideMatch.Groups[1].Value)));
|
|
continue;
|
|
}
|
|
Match cellsMatch = DrawCellsPattern.Match(line);
|
|
if (cellsMatch.Success)
|
|
{
|
|
uint[] cells = cellsMatch.Groups[3].Value
|
|
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
|
.Select(ParseId)
|
|
.ToArray();
|
|
current.Add(WalkOracleEvent.DrawCells(
|
|
outsideViewCount: int.Parse(
|
|
cellsMatch.Groups[1].Value, CultureInfo.InvariantCulture),
|
|
declaredCount: int.Parse(
|
|
cellsMatch.Groups[2].Value, CultureInfo.InvariantCulture),
|
|
cells));
|
|
continue;
|
|
}
|
|
// S3 chunk 3 (§9.3 T1): LC/SC — RenderDeviceD3D::DrawLandCell
|
|
// 0x0059f120 / DrawSortCell 0x0059f140, added to the OH capture
|
|
// templates 2026-09-03 (tools/walk-oracle/oh/oh-capture-walk.cdb.template).
|
|
Match landCellMatch = LandCellPattern.Match(line);
|
|
if (landCellMatch.Success)
|
|
{
|
|
current.Add(WalkOracleEvent.LandCell(ParseId(landCellMatch.Groups[1].Value)));
|
|
continue;
|
|
}
|
|
Match sortCellMatch = SortCellPattern.Match(line);
|
|
if (sortCellMatch.Success)
|
|
{
|
|
current.Add(WalkOracleEvent.SortCell(ParseId(sortCellMatch.Groups[1].Value)));
|
|
continue;
|
|
}
|
|
// Anything else is cdb chrome (banner, prompts, symbol notes) — ignored.
|
|
}
|
|
|
|
// The last STARTED frame (still in `current`) is deliberately never
|
|
// appended — that is the truncated detach frame (see class doc).
|
|
return frames;
|
|
}
|
|
|
|
public static IReadOnlyList<WalkOracleFrame> Load(string fixtureName)
|
|
=> Load("docs/research/2026-08-30-fw-walk-oracle", fixtureName);
|
|
|
|
/// <summary>S3 chunk 3 (§9.3 T1): loads a fixture from an arbitrary
|
|
/// repo-relative <paramref name="root"/> — the OH captures live under
|
|
/// <c>docs/research/2026-09-01-overhaul/oh-capture/</c>, a different
|
|
/// directory than the FW0 still fixtures the single-argument overload
|
|
/// defaults to.</summary>
|
|
public static IReadOnlyList<WalkOracleFrame> Load(string root, string fixtureName)
|
|
{
|
|
string repoRoot = FindRepositoryRoot();
|
|
string path = Path.Combine(
|
|
repoRoot, Path.Combine(root.Split('/')), fixtureName + ".log");
|
|
return Parse(File.ReadLines(path));
|
|
}
|
|
|
|
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 uint ParseId(string hex)
|
|
=> uint.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
|
|
|
private static readonly Regex FramePattern = new(@"^F (\d+)\s*$", RegexOptions.Compiled);
|
|
private static readonly Regex PosePattern = new(
|
|
@"^P ([0-9a-f]{8}) ([0-9a-f]{8}) ([0-9a-f]{8}) ([0-9a-f]{8}) ([0-9a-f]{8}) ([0-9a-f]{8}) ([0-9a-f]{8}) ([0-9a-f]{8})\s*$",
|
|
RegexOptions.Compiled);
|
|
private static readonly Regex BuildingPattern = new(@"^BLD ([0-9a-f]{8})\s*$", RegexOptions.Compiled);
|
|
private static readonly Regex DrawInsidePattern = new(@"^DI ([0-9a-f]{8})\s*$", RegexOptions.Compiled);
|
|
private static readonly Regex DrawCellsPattern = new(
|
|
@"^DC pv=[0-9a-f]{8} ov=(\d+) n=(\d+):((?: [0-9a-f]{8})*)\s*$", RegexOptions.Compiled);
|
|
private static readonly Regex LandCellPattern = new(@"^LC ([0-9a-f]{8})\s*$", RegexOptions.Compiled);
|
|
private static readonly Regex SortCellPattern = new(@"^SC ([0-9a-f]{8})\s*$", RegexOptions.Compiled);
|
|
}
|
|
|
|
/// <summary>The camera pose dumped at the frame marker (raw dwords from
|
|
/// <c>Render::viewer_pos</c> @0x0081ef00): the camera's cell id, world
|
|
/// origin, and the Frame quaternion's four raw components (q0..q3 in
|
|
/// storage order — axis convention resolved by the replay runner).
|
|
/// Present only in the pose-stamped capture round.</summary>
|
|
public sealed record WalkOraclePose(
|
|
uint CellId,
|
|
System.Numerics.Vector3 Origin,
|
|
float Q0,
|
|
float Q1,
|
|
float Q2,
|
|
float Q3);
|
|
|
|
public sealed record WalkOracleFrame(
|
|
int Number,
|
|
IReadOnlyList<WalkOracleEvent> Events,
|
|
WalkOraclePose? Pose = null)
|
|
{
|
|
/// <summary>The frame's root: the first DrawInside cell, or null when outdoor-rooted.</summary>
|
|
public uint? InteriorRootCell
|
|
=> Events.FirstOrDefault(e => e.Kind == WalkOracleEventKind.DrawInside)?.CellId;
|
|
|
|
public bool HasLandscape => Events.Any(e => e.Kind == WalkOracleEventKind.Landscape);
|
|
|
|
public IEnumerable<uint> Buildings
|
|
=> Events.Where(e => e.Kind == WalkOracleEventKind.Building).Select(e => e.CellId!.Value);
|
|
}
|
|
|
|
public enum WalkOracleEventKind
|
|
{
|
|
Landscape,
|
|
Building,
|
|
DrawInside,
|
|
DrawCells,
|
|
|
|
/// <summary>S3 chunk 3: <c>LC <cellid></c> — <c>RenderDeviceD3D::
|
|
/// DrawLandCell</c> @0x0059f120 entry.</summary>
|
|
LandCell,
|
|
|
|
/// <summary>S3 chunk 3: <c>SC <cellid></c> — <c>RenderDeviceD3D::
|
|
/// DrawSortCell</c> @0x0059f140 entry.</summary>
|
|
SortCell,
|
|
}
|
|
|
|
public sealed record WalkOracleEvent(
|
|
WalkOracleEventKind Kind,
|
|
uint? CellId,
|
|
int OutsideViewCount,
|
|
int DeclaredCellCount,
|
|
IReadOnlyList<uint> Cells)
|
|
{
|
|
public static WalkOracleEvent Landscape()
|
|
=> new(WalkOracleEventKind.Landscape, null, 0, 0, Array.Empty<uint>());
|
|
|
|
public static WalkOracleEvent Building(uint cellId)
|
|
=> new(WalkOracleEventKind.Building, cellId, 0, 0, Array.Empty<uint>());
|
|
|
|
public static WalkOracleEvent DrawInside(uint cellId)
|
|
=> new(WalkOracleEventKind.DrawInside, cellId, 0, 0, Array.Empty<uint>());
|
|
|
|
public static WalkOracleEvent DrawCells(
|
|
int outsideViewCount, int declaredCount, IReadOnlyList<uint> cells)
|
|
=> new(WalkOracleEventKind.DrawCells, null, outsideViewCount, declaredCount, cells);
|
|
|
|
public static WalkOracleEvent LandCell(uint cellId)
|
|
=> new(WalkOracleEventKind.LandCell, cellId, 0, 0, Array.Empty<uint>());
|
|
|
|
public static WalkOracleEvent SortCell(uint cellId)
|
|
=> new(WalkOracleEventKind.SortCell, cellId, 0, 0, Array.Empty<uint>());
|
|
}
|