Campaign OVERHAUL S3 chunk 4 (docs/research/2026-09-01-overhaul/s3-walk-ownership-map.md §10). Retail
polygon-clips exactly three things on the CPU — portal polygons (PView::GetClip), punch fans
(DrawPortalPolyInternal against building_view), and exit seals (setup_view + polyClipFinish);
ordinary meshes, cell shells, terrain, particles and the weather are never view-clipped. Every
acdream-only clip rule beyond those three is deleted; the two that remain get a new CPU/GPU
equivalence pin.
DELETED (no retail counterpart):
- RetailPViewRenderer.DrawLandscapeDynamicsPhase's per-outside-view-slice weather loop
(SetTerrainClip + ClearClipRouting + the old DrawLandscapeSliceLate leaf, one call per active
landscape view) — retail's GameSky::Draw(sky,1) @0x00506ff0 runs ONCE, unclipped, after
LScape::draw's whole block loop. RetailPViewPassExecutor.DrawWeatherOnce stays the one call site;
it now also submits the rain particles (ParticleRenderPass.SkyPostScene) as ONE unclipped
submission, folded in from the deleted loop's per-slice clipSlot draw.
- RetailPViewPassExecutor.DrawLandscapeSliceLate, RetailPViewLandscapeLateSliceContext,
SetTerrainClip (the walk-level wrapper — its only caller was the deleted loop),
ClearClipRouting (same), SetCellShellClipRouting (already had no caller), and
UseIndoorMembershipOnlyRouting (4 call sites in RetailPViewRenderer.cs) — all "first view clips
the shell" residue: SetCellShellClipRouting was the ONLY place EnvCellRenderer.SetClipRouting
ever received a non-null value anywhere in the app, so once it's gone,
UseIndoorMembershipOnlyRouting's own clear calls are provably permanent no-ops. AbortFrame's
matching TryAbort(SetClipRouting(null))/TryAbort(ClearClipRouting) lines are dropped for the
same reason. KEPT: BeginDoorwayScissor, _surface.BindTerrainClip(), EnableClipDistances/
DisableClipDistances, and IWorldPassSurface.SetTerrainClip/ClipFrame.SetTerrainClip/
PrepareClipFrame's terrain-clip publish — all still have real consumers (DrawWalkSky's per-slice
outdoor sky mesh draw; the always-bound TerrainClip UBO section) or are shared classic (non-walk)
infrastructure out of this chunk's ownership (WorldScenePassExecutor, PortalTunnelPresentation).
- WbDrawDispatcher.WalkClassify.ResolvePartClipSlots (renamed ResolvePartVisible, returns bool) and
RetailWholeMeshSlot: the per-view clip-slot LIST always collapsed to either nothing or one
element (RenderDeviceD3D::DrawMesh @0x005A0860 submits the whole mesh once any view admits it) —
never anything but a Boolean wearing a list-shaped costume. IWalkLookInViewSource.
VisibleClipSlotsInLookInTurn is deleted outright (no consumer besides that collapse);
SphereVisibleInLookInTurn gained VisibleClipSlotsInLookInTurn's own testSphere parameter so the
no-authored-sphere case ("any admitted view, geometry ignored") still has a home.
EmitClassifiedBatches now emits exactly one batch per surviving TryClassifyBatch using the
entity's own classic slot (always 0 while _clipRoutingActive is never armed by the walk path).
- The six ProbeCathedralSkip*/ProbeCathedralShellOrderEnabled discriminators (RenderingDiagnostics,
their six docs/launch-options.md rows, every read site in WalkFrameDriver/
RetailPViewPassExecutor.WalkLeaf/RetailPViewRenderer, and the WalkFrameDriver.
TraceCathedralShellOrder method + RetailPViewRenderer._probeCathedralShellOrderFrame it served) —
the investigation is closed: the cathedral leak is retail behavior, not an acdream defect to
discriminate.
O1-O4 (the ledger's carried chunk-1 items, closed here because this chunk owns the weather path):
IWalkEventSink gains OnWeatherTurn(viewerCellId), fired by RetailFrameWalk.DrawLandscape
UNCONDITIONALLY at retail's own call-site shape (GameSky::Draw(sky,1) is itself unconditional from
LScape::draw; the is_player_outside gate lives inside it) but gated by the walk context's own new
WeatherGateOpen/ViewerCellId members (default false/0, so every other IRetailFrameWalkContext
implementer — test fixtures, the FW1 conformance replay context — is unaffected).
WalkProductionFrameContext wires RetailPViewFrameInput.ViewerCellId and
RetailPViewPassExecutor.ShouldDrawWeatherOnce(RenderSky, RenderWeather, PlayerCellId) into those two
members. WalkFrameDriver.OnWeatherTurn prints the "OC" transcript line at COLLECT time; the print is
removed from DrawWeatherOnce (the DRAW stays there, at Replay). Because DrawLandscape's new call sits
at the very end of the method, an outdoor root's transcript naturally ends with the line and an
interior root's lands strictly between "LS" and the flood's first "EC" — exactly retail's position —
with no extra plumbing.
Kept items each get a synthetic-view CPU/GPU equivalence pin (ClipFrameLayoutTests): exit seals via
the real ClipFrame.AppendSlot/GetSlotPlanes round trip, punch fans via the direct
ClipViewSlice.Planes = cps.PlaneArray assignment ClipFrameAssembler.Assemble uses — both assert every
polygon edge midpoint has ~0 signed distance under the GPU-read-back planes and non-negative distance
under every plane.
Every new pin's mutation was hand-verified this session (temporarily reintroduced, confirmed the
exact failing assertion, then reverted): a duplicated OnWeatherTurn call fails Assert.Single with
"2 matching items" on both the outdoor and interior position tests; suppressing the Collect-time
call fails the same assertions with "did not contain any matching items"; perturbing one GPU plane's
offset by 0.05 fails the exit-seal equivalence pin with "the closest plane was only 0.050000012
away". WalkProductionFrameContextTests pins that the ViewerCellId/WeatherGateOpen storage seam
itself never drops or swaps its own two constructor/Reset arguments.
No register row added or removed — every change here deletes an acdream-only rule; none introduces
a new deviation. Full solution build: 0 warnings/0 errors. App hermetic 6,829/6,829 (was 6,828 base
+ 1 new pin). InstalledDat 244 pass/1 skip/4 known (2x #383, TowerAscent, and the pre-existing #458
WalkLandscape.CheckBlocks block-visibility divergence — unrelated to this chunk, untouched by it).
Core.Tests Rendering 216/216.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
881 lines
35 KiB
C#
881 lines
35 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Collections.ObjectModel;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Numerics;
|
|
using System.Reflection;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.Rendering.Gpu.Vk;
|
|
using AcDream.App.Rendering.Scene;
|
|
using AcDream.App.Rendering.Selection;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Rendering.Walk;
|
|
using AcDream.App.Tests.Rendering.Gpu;
|
|
using AcDream.Content;
|
|
using AcDream.Core.Meshing;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using DatReaderWriter.Lib.IO;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Walk;
|
|
|
|
/// <summary>
|
|
/// Campaign FW stage FW3.2a: the walk→draw population layer's data-
|
|
/// equivalence referee. Covers <see cref="WbDrawDispatcher.ClassifyEntityForWalk"/>
|
|
/// (the shared per-entity classify seam), <see cref="WalkStaticStreamPopulator"/>
|
|
/// (opaque → <see cref="OrderedDrawStream"/>, translucent → the alpha queue,
|
|
/// selection publish), and the FW3.2a own-cull-scratch fix to the ordered
|
|
/// submitter (<c>PrepareOrderedStream</c>/<c>DrawOrderedRange</c> as of
|
|
/// Campaign FW stage FW3.4a).
|
|
/// </summary>
|
|
public sealed class WalkStaticStreamPopulatorTests
|
|
{
|
|
// ── Test doubles ────────────────────────────────────────────────────────
|
|
|
|
private sealed class RecordingSelectionSink : IRetailSelectionRenderSink
|
|
{
|
|
public readonly List<(uint ServerGuid, uint LocalEntityId, int PartIndex, uint GfxObjId, Matrix4x4 LocalToWorld)>
|
|
Calls = new();
|
|
|
|
public void AddVisiblePart(
|
|
uint serverGuid, uint localEntityId, int partIndex, uint gfxObjId, Matrix4x4 partWorld) =>
|
|
Calls.Add((serverGuid, localEntityId, partIndex, gfxObjId, partWorld));
|
|
}
|
|
|
|
private sealed class FixedWalkViews(params uint[] slots) : IWalkLookInViewSource
|
|
{
|
|
public IReadOnlyList<uint> LookInCellTurns { get; } = [0x8C040112u];
|
|
|
|
public bool SphereVisibleInLookInTurn(
|
|
int routeIndex,
|
|
in Vector3 center,
|
|
float radius,
|
|
bool testSphere = true) => slots.Length != 0;
|
|
}
|
|
|
|
// ── Synthetic RenderProjectionRecord construction ──────────────────────
|
|
|
|
private static RenderProjectionRecord MakeRecord(
|
|
uint localEntityId,
|
|
uint serverGuid,
|
|
Vector3 position,
|
|
IReadOnlyList<MeshRef> meshRefs,
|
|
bool isBuildingShell = false,
|
|
uint parentCellId = 0u,
|
|
RenderCasterIdentityKind casterIdentity =
|
|
RenderCasterIdentityKind.Unclassified) =>
|
|
new(
|
|
Id: RenderProjectionId.FromRaw(localEntityId),
|
|
ProjectionClass: RenderProjectionClass.OutdoorStatic,
|
|
OwnerIncarnation: RenderOwnerIncarnation.FromRaw(1),
|
|
Transform: new RenderTransform(Matrix4x4.CreateTranslation(position)),
|
|
PreviousTransform: default,
|
|
MeshSet: default,
|
|
Material: default,
|
|
Residency: default,
|
|
Bounds: default,
|
|
Flags: RenderProjectionFlags.Draw,
|
|
DegradeState: default,
|
|
SortKey: new RenderSortKey(0),
|
|
DirtyMask: default,
|
|
Source: new RenderSourceMetadata(
|
|
LocalEntityId: localEntityId,
|
|
ServerGuid: serverGuid,
|
|
SourceId: 0,
|
|
ParentCellId: parentCellId,
|
|
EffectCellId: 0,
|
|
BuildingShellAnchorCellId: 0,
|
|
TransformFingerprint: default,
|
|
GeometryFingerprint: default,
|
|
AppearanceFingerprint: default),
|
|
EntityPayload: new RenderEntityPayload(
|
|
MeshRefs: meshRefs,
|
|
PaletteOverride: null,
|
|
IsBuildingShell: isBuildingShell,
|
|
CasterIdentity: casterIdentity));
|
|
|
|
private static ObjectRenderBatch MakeBatch(
|
|
uint surfaceId,
|
|
TranslucencyKind translucency,
|
|
uint firstIndex,
|
|
int baseVertex,
|
|
int indexCount,
|
|
uint textureSlotIndex,
|
|
uint textureLayer = 0,
|
|
CullMode cullMode = CullMode.CounterClockwise) =>
|
|
new()
|
|
{
|
|
Key = new TextureKey { SurfaceId = surfaceId, IsSolid = false },
|
|
Translucency = translucency,
|
|
FirstIndex = firstIndex,
|
|
BaseVertex = (uint)baseVertex,
|
|
IndexCount = indexCount,
|
|
TextureSlot = new GpuTextureSlot(textureSlotIndex),
|
|
TextureIndex = (int)textureLayer,
|
|
};
|
|
|
|
private static ObjectRenderData MakeFlatMesh(params ObjectRenderBatch[] batches) =>
|
|
new() { Batches = new List<ObjectRenderBatch>(batches) };
|
|
|
|
// ── Reflection seam: ObjectMeshManager owns no test-injection API, and
|
|
// driving real GPU/GfxObj upload for a unit test is out of this stage's
|
|
// scope — ObjectRenderData/ObjectRenderBatch are plain settable classes,
|
|
// so this seeds the manager's private cache directly. ──────────────────
|
|
|
|
private static void InjectRenderData(ObjectMeshManager manager, ulong id, ObjectRenderData data)
|
|
{
|
|
FieldInfo field = typeof(ObjectMeshManager).GetField(
|
|
"_renderData", BindingFlags.NonPublic | BindingFlags.Instance)
|
|
?? throw new InvalidOperationException(
|
|
"ObjectMeshManager._renderData field not found — test relies on this exact name.");
|
|
var dict = (ConcurrentDictionary<ulong, ObjectRenderData>)field.GetValue(manager)!;
|
|
dict[id] = data;
|
|
}
|
|
|
|
// ── Deliverable 1: ClassifyEntityForWalk data equivalence ─────────────
|
|
|
|
[Fact]
|
|
public void ClassifyEntityForWalk_OneOpaqueAndOneTranslucentPart_YieldsBatchesInRecordOrderWithCorrectIsOpaque()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong opaqueGfxObj = 0x0100_0001UL;
|
|
const ulong alphaGfxObj = 0x0100_0002UL;
|
|
InjectRenderData(fx.Manager, opaqueGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000001u, TranslucencyKind.Opaque, firstIndex: 0, baseVertex: 0, indexCount: 3, textureSlotIndex: 1)));
|
|
InjectRenderData(fx.Manager, alphaGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000002u, TranslucencyKind.AlphaBlend, firstIndex: 3, baseVertex: 4, indexCount: 6, textureSlotIndex: 2)));
|
|
|
|
var meshRefs = new[]
|
|
{
|
|
new MeshRef((uint)opaqueGfxObj, Matrix4x4.CreateTranslation(1, 0, 0)),
|
|
new MeshRef((uint)alphaGfxObj, Matrix4x4.CreateTranslation(0, 1, 0)),
|
|
};
|
|
RenderProjectionRecord record = MakeRecord(
|
|
localEntityId: 100, serverGuid: 0, position: new Vector3(5, 6, 7), meshRefs);
|
|
|
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
|
fx.Dispatcher.ClassifyEntityForWalk(in record, tupleLandblockId: 0x8C04u, batches, selectionParts);
|
|
|
|
Assert.Equal(2, batches.Count);
|
|
|
|
WbDrawDispatcher.WalkClassifiedBatch opaque = batches[0];
|
|
Assert.True(opaque.IsOpaque);
|
|
Assert.Equal(TranslucencyKind.Opaque, opaque.Key.Translucency);
|
|
Assert.Equal(0u, opaque.Key.FirstIndex);
|
|
Assert.Equal(3, opaque.Key.IndexCount);
|
|
Assert.Equal(1u, opaque.Key.TextureSlot.Index);
|
|
Assert.Equal(1f, opaque.Alpha);
|
|
Assert.Equal(meshRefs[0].PartTransform * record.Transform.LocalToWorld, opaque.Transform);
|
|
|
|
WbDrawDispatcher.WalkClassifiedBatch translucent = batches[1];
|
|
Assert.False(translucent.IsOpaque);
|
|
Assert.Equal(TranslucencyKind.AlphaBlend, translucent.Key.Translucency);
|
|
Assert.Equal(3u, translucent.Key.FirstIndex);
|
|
Assert.Equal(6, translucent.Key.IndexCount);
|
|
Assert.Equal(2u, translucent.Key.TextureSlot.Index);
|
|
Assert.Equal(meshRefs[1].PartTransform * record.Transform.LocalToWorld, translucent.Transform);
|
|
|
|
Assert.Equal(2, selectionParts.Count);
|
|
Assert.Equal(100u, selectionParts[0].LocalEntityId);
|
|
Assert.Equal(0, selectionParts[0].PartIndex);
|
|
Assert.Equal((uint)opaqueGfxObj, selectionParts[0].GfxObjId);
|
|
Assert.Equal(opaque.Transform, selectionParts[0].LocalToWorld);
|
|
Assert.Equal(1, selectionParts[1].PartIndex);
|
|
Assert.Equal((uint)alphaGfxObj, selectionParts[1].GfxObjId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClassifyEntityForWalk_NoDrawProjectileRecord_SubmitsNeitherMeshNorSelection()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong projectileGfxObj = 0x0100_0003UL;
|
|
InjectRenderData(fx.Manager, projectileGfxObj, MakeFlatMesh(
|
|
MakeBatch(
|
|
0x08000003u,
|
|
TranslucencyKind.Opaque,
|
|
firstIndex: 0,
|
|
baseVertex: 0,
|
|
indexCount: 3,
|
|
textureSlotIndex: 1)));
|
|
|
|
RenderProjectionRecord projectile = MakeRecord(
|
|
localEntityId: 101,
|
|
serverGuid: 0x7000_0101u,
|
|
position: Vector3.Zero,
|
|
meshRefs: [new MeshRef((uint)projectileGfxObj, Matrix4x4.Identity)])
|
|
with
|
|
{
|
|
// ACE's projectile-impact SetState sets NoDraw immediately;
|
|
// the later DeleteObject intentionally arrives five seconds
|
|
// afterward. The journal projects that state as resident and
|
|
// hidden, with Draw cleared.
|
|
Flags = RenderProjectionFlags.SpatiallyResident
|
|
| RenderProjectionFlags.Selectable
|
|
| RenderProjectionFlags.Hidden,
|
|
};
|
|
|
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
var selectionParts =
|
|
new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
|
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in projectile,
|
|
tupleLandblockId: 0x8C04u,
|
|
batches,
|
|
selectionParts,
|
|
liveDynamic: true);
|
|
|
|
Assert.Empty(batches);
|
|
Assert.Empty(selectionParts);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClassifyEntityForWalk_SetupComposite_EncodesPartAndSetupPartIndexLikePackedRoute()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong setupGfxObj = 0x1000_0010UL;
|
|
const ulong trunkGfxObj = 0x0100_0011UL;
|
|
const ulong leavesGfxObj = 0x0100_0012UL;
|
|
|
|
InjectRenderData(fx.Manager, trunkGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000011u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
InjectRenderData(fx.Manager, leavesGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000012u, TranslucencyKind.ClipMap, 3, 4, 6, 2)));
|
|
InjectRenderData(fx.Manager, setupGfxObj, new ObjectRenderData
|
|
{
|
|
IsSetup = true,
|
|
SetupParts = new List<(ulong GfxObjId, Matrix4x4 Transform)>
|
|
{
|
|
(trunkGfxObj, Matrix4x4.CreateTranslation(0, 0, 1)),
|
|
(leavesGfxObj, Matrix4x4.CreateTranslation(0, 0, 2)),
|
|
},
|
|
});
|
|
|
|
var meshRefs = new[] { new MeshRef((uint)setupGfxObj, Matrix4x4.Identity) };
|
|
RenderProjectionRecord record = MakeRecord(200, 0, Vector3.Zero, meshRefs);
|
|
|
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
|
fx.Dispatcher.ClassifyEntityForWalk(in record, 0x8C04u, batches, selectionParts);
|
|
|
|
Assert.Equal(2, batches.Count);
|
|
Assert.Equal(2, selectionParts.Count);
|
|
// partIndex=0 (the entity's single top-level MeshRef) << 16 | setupPartIndex.
|
|
Assert.Equal(0, selectionParts[0].PartIndex);
|
|
Assert.Equal(1, selectionParts[1].PartIndex);
|
|
Assert.Equal((uint)trunkGfxObj, selectionParts[0].GfxObjId);
|
|
Assert.Equal((uint)leavesGfxObj, selectionParts[1].GfxObjId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClassifyEntityForWalk_FrameScopeStampsEachAdmittedSetupPartOncePerRetailPass()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong setupGfxObj = 0x1000_0020UL;
|
|
const ulong headGfxObj = 0x0100_0021UL;
|
|
const ulong torsoGfxObj = 0x0100_0022UL;
|
|
|
|
InjectRenderData(fx.Manager, headGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000021u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
InjectRenderData(fx.Manager, torsoGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000022u, TranslucencyKind.Opaque, 3, 4, 6, 2)));
|
|
InjectRenderData(fx.Manager, setupGfxObj, new ObjectRenderData
|
|
{
|
|
IsSetup = true,
|
|
SetupParts = new List<(ulong GfxObjId, Matrix4x4 Transform)>
|
|
{
|
|
(headGfxObj, Matrix4x4.CreateTranslation(0, 0, 2)),
|
|
(torsoGfxObj, Matrix4x4.CreateTranslation(0, 0, 1)),
|
|
},
|
|
});
|
|
|
|
RenderProjectionRecord record = MakeRecord(
|
|
220,
|
|
0,
|
|
Vector3.Zero,
|
|
[new MeshRef((uint)setupGfxObj, Matrix4x4.Identity)]);
|
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
|
|
|
fx.Dispatcher.BeginWalkPartFrame();
|
|
try
|
|
{
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in record, 0x8C04u, batches, selectionParts);
|
|
Assert.Equal(2, batches.Count);
|
|
Assert.Equal(2, selectionParts.Count);
|
|
|
|
batches.Clear();
|
|
selectionParts.Clear();
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in record, 0x8C04u, batches, selectionParts);
|
|
Assert.Empty(batches);
|
|
Assert.Empty(selectionParts);
|
|
|
|
// PView::DrawCells @0x005A4886 increments m_nFrameStamp after
|
|
// the landscape pass and before its interior-cell repaint. The
|
|
// same parts may therefore submit once again in the second pass.
|
|
fx.Dispatcher.AdvanceWalkPartPassStamp();
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in record, 0x8C04u, batches, selectionParts);
|
|
Assert.Equal(2, batches.Count);
|
|
Assert.Equal(2, selectionParts.Count);
|
|
}
|
|
finally
|
|
{
|
|
fx.Dispatcher.EndWalkPartFrame();
|
|
}
|
|
|
|
// Direct classifier calls outside a production walk frame remain
|
|
// independent, as the conformance/referee tests require.
|
|
batches.Clear();
|
|
selectionParts.Clear();
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in record, 0x8C04u, batches, selectionParts);
|
|
Assert.Equal(2, batches.Count);
|
|
Assert.Equal(2, selectionParts.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClassifyEntityForWalk_FrameScopeDoesNotStampPortalRejectedPart()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0100_0023UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000023u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
RenderProjectionRecord record = MakeRecord(
|
|
221,
|
|
0,
|
|
Vector3.Zero,
|
|
[new MeshRef((uint)gfxObj, Matrix4x4.Identity)]);
|
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
|
|
|
fx.Dispatcher.BeginWalkPartFrame();
|
|
try
|
|
{
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in record,
|
|
0x8C04u,
|
|
batches,
|
|
selectionParts,
|
|
liveDynamic: true,
|
|
lookInViews: new FixedWalkViews(),
|
|
lookInRouteIndex: 0);
|
|
Assert.Empty(batches);
|
|
Assert.Empty(selectionParts);
|
|
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in record,
|
|
0x8C04u,
|
|
batches,
|
|
selectionParts,
|
|
liveDynamic: true,
|
|
lookInViews: new FixedWalkViews(7u),
|
|
lookInRouteIndex: 1);
|
|
Assert.Single(batches);
|
|
Assert.Single(selectionParts);
|
|
}
|
|
finally
|
|
{
|
|
fx.Dispatcher.EndWalkPartFrame();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ClassifyEntityForWalk_LocalPlayerBypassesDrawnPartStampLikeRetail()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0100_0024UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000024u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
RenderProjectionRecord player = MakeRecord(
|
|
222,
|
|
0x5000_0001u,
|
|
Vector3.Zero,
|
|
[new MeshRef((uint)gfxObj, Matrix4x4.Identity)],
|
|
casterIdentity: RenderCasterIdentityKind.LocalPlayer);
|
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
|
|
|
fx.Dispatcher.BeginWalkPartFrame();
|
|
try
|
|
{
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in player, 0xF418u, batches, selectionParts,
|
|
liveDynamic: true);
|
|
Assert.Single(batches);
|
|
Assert.Single(selectionParts);
|
|
|
|
batches.Clear();
|
|
selectionParts.Clear();
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in player, 0xF418u, batches, selectionParts,
|
|
liveDynamic: true);
|
|
Assert.Single(batches);
|
|
Assert.Single(selectionParts);
|
|
}
|
|
finally
|
|
{
|
|
fx.Dispatcher.EndWalkPartFrame();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ClassifyEntityForWalk_PortalViewsAdmitOneCompleteUnclippedMesh()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0100_0013UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000013u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
RenderProjectionRecord record = MakeRecord(
|
|
201, 0, Vector3.Zero,
|
|
[new MeshRef((uint)gfxObj, Matrix4x4.Identity)],
|
|
parentCellId: 0x8C040112u);
|
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
|
var views = new FixedWalkViews(7u, 9u);
|
|
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in record,
|
|
0x8C04u,
|
|
batches,
|
|
selectionParts,
|
|
liveDynamic: true,
|
|
views,
|
|
lookInRouteIndex: 0,
|
|
lookInCellId: 0x8C040112u);
|
|
|
|
WbDrawDispatcher.WalkClassifiedBatch batch = Assert.Single(batches);
|
|
Assert.Equal(0u, batch.ClipSlot);
|
|
Assert.Single(selectionParts);
|
|
}
|
|
|
|
// ── Deliverable 2: WalkStaticStreamPopulator routing ───────────────────
|
|
|
|
[Fact]
|
|
public void PopulateCell_OpaqueBatchAppendsOrderedDrawCommandInRecordOrderWithStageAndCellProvenance()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0100_0003UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000003u, TranslucencyKind.Opaque, 10, 2, 12, 5)));
|
|
|
|
var record = MakeRecord(300, 0, new Vector3(1, 2, 3), new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
|
|
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
|
|
var stream = new OrderedDrawStream();
|
|
|
|
populator.PopulateCell(
|
|
stream, WalkDrawStage.CellStatic, cellId: 0x8C040100u,
|
|
new[] { record }, tupleLandblockId: 0x8C04u,
|
|
cameraWorldPosition: Vector3.Zero, viewProjection: Matrix4x4.Identity);
|
|
|
|
Assert.Equal(1, stream.Count);
|
|
Assert.Equal(WalkDrawStage.CellStatic, stream.Stages[0]);
|
|
Assert.Equal(0x8C040100u, stream.CellIds[0]);
|
|
Assert.Equal(10u, stream.Keys[0].FirstIndex);
|
|
Assert.Equal(12, stream.Keys[0].IndexCount);
|
|
Assert.Equal(1f, stream.Alphas[0]);
|
|
Assert.Equal(record.Transform.LocalToWorld, stream.Transforms[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void PopulateOutdoorStatics_UsesTheOutdoorStaticStage()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0100_0004UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000004u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
|
|
var record = MakeRecord(400, 0, Vector3.Zero, new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
|
|
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
|
|
var stream = new OrderedDrawStream();
|
|
|
|
populator.PopulateOutdoorStatics(
|
|
stream, cellId: 0x8C040000u, new[] { record }, tupleLandblockId: 0x8C04u,
|
|
cameraWorldPosition: Vector3.Zero, viewProjection: Matrix4x4.Identity);
|
|
|
|
Assert.Equal(1, stream.Count);
|
|
Assert.Equal(WalkDrawStage.OutdoorStatic, stream.Stages[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void PopulateCell_TranslucentBatchDoesNotAppendToTheStreamAndReachesTheAlphaQueue()
|
|
{
|
|
using var fx = new DispatcherFixture(withAlphaQueue: true);
|
|
const ulong gfxObj = 0x0100_0005UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000005u, TranslucencyKind.AlphaBlend, 0, 0, 3, 1)));
|
|
|
|
var record = MakeRecord(500, 0, new Vector3(0, 0, 10), new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
|
|
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
|
|
var stream = new OrderedDrawStream();
|
|
|
|
fx.AlphaQueue!.BeginFrame();
|
|
populator.PopulateCell(
|
|
stream, WalkDrawStage.CellStatic, 0x8C040100u, new[] { record }, 0x8C04u,
|
|
cameraWorldPosition: Vector3.Zero, viewProjection: Matrix4x4.Identity);
|
|
|
|
Assert.Equal(0, stream.Count);
|
|
Assert.Equal(1, fx.AlphaQueue.PendingCount);
|
|
fx.AlphaQueue.AbortFrame();
|
|
}
|
|
|
|
[Fact]
|
|
public void PopulateCell_PublishesSelectionPartsForEveryClassifiedEntity()
|
|
{
|
|
var sink = new RecordingSelectionSink();
|
|
using var fx = new DispatcherFixture(selectionSink: sink);
|
|
const ulong gfxObj = 0x0100_0006UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000006u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
|
|
var record = MakeRecord(600, serverGuid: 0x8000_0060u, new Vector3(1, 1, 1),
|
|
new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
|
|
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
|
|
var stream = new OrderedDrawStream();
|
|
|
|
populator.PopulateCell(
|
|
stream, WalkDrawStage.CellStatic, 0x8C040100u, new[] { record }, 0x8C04u,
|
|
Vector3.Zero, Matrix4x4.Identity);
|
|
|
|
var call = Assert.Single(sink.Calls);
|
|
Assert.Equal(0x8000_0060u, call.ServerGuid);
|
|
Assert.Equal(600u, call.LocalEntityId);
|
|
Assert.Equal(0, call.PartIndex);
|
|
Assert.Equal((uint)gfxObj, call.GfxObjId);
|
|
Assert.Equal(record.Transform.LocalToWorld, call.LocalToWorld);
|
|
}
|
|
|
|
// ── SubmitWalkAlphaInstance: same viewer distance + per-instance data as
|
|
// DeferTransparentGroups, through the REAL RetailAlphaQueue. ───────────
|
|
|
|
[Fact]
|
|
public void SubmitWalkAlphaInstance_SubmitsTheSameViewerDistanceComputeViewerDistanceWouldProduce()
|
|
{
|
|
using var fx = new DispatcherFixture(withAlphaQueue: true);
|
|
fx.AlphaQueue!.BeginFrame();
|
|
|
|
var key = new GroupKey(10, 2, 6, new GpuTextureSlot(3), 1, TranslucencyKind.AlphaBlend, FoliageFlags: 0);
|
|
Vector3 localSortCenter = new(1, 2, 3);
|
|
Matrix4x4 model = Matrix4x4.CreateTranslation(4, 5, 6);
|
|
var cameraWorldPosition = Vector3.Zero;
|
|
var batch = new WbDrawDispatcher.WalkClassifiedBatch(
|
|
key, model, ClipSlot: 7, WbDrawDispatcher.InstanceLightSet.Disabled, IndoorFlag: 1,
|
|
Alpha: 0.5f, SelectionLighting: new Vector2(0.25f, 0.75f), DetailCategory: 1,
|
|
IsOpaque: false, LocalSortCenter: localSortCenter);
|
|
|
|
fx.Dispatcher.SubmitWalkAlphaInstance(in batch, cameraWorldPosition, Matrix4x4.Identity);
|
|
|
|
Assert.Equal(1, fx.AlphaQueue.PendingCount);
|
|
|
|
// Same call DeferTransparentGroups makes per instance — independently
|
|
// computed here so the assertion cannot pass by construction.
|
|
float expectedDistance = RetailAlphaOrdering.ComputeViewerDistance(
|
|
localSortCenter, model, cameraWorldPosition);
|
|
|
|
FieldInfo submissionsField = typeof(RetailAlphaQueue).GetField(
|
|
"_submissions", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
|
var submissions = (List<RetailAlphaSubmission>)submissionsField.GetValue(fx.AlphaQueue)!;
|
|
RetailAlphaSubmission submission = Assert.Single(submissions);
|
|
Assert.Equal(expectedDistance, submission.ViewerDistance, precision: 4);
|
|
Assert.Equal(0, submission.Token);
|
|
|
|
fx.AlphaQueue.AbortFrame();
|
|
}
|
|
|
|
[Fact]
|
|
public void SubmitWalkAlphaInstance_RejectsAMismatchedViewProjectionInTheSameScope()
|
|
{
|
|
using var fx = new DispatcherFixture(withAlphaQueue: true);
|
|
fx.AlphaQueue!.BeginFrame();
|
|
|
|
var key = new GroupKey(0, 0, 3, new GpuTextureSlot(1), 0, TranslucencyKind.AlphaBlend, FoliageFlags: 0);
|
|
var batch = new WbDrawDispatcher.WalkClassifiedBatch(
|
|
key, Matrix4x4.Identity, 0, WbDrawDispatcher.InstanceLightSet.Disabled, 0, 1f,
|
|
Vector2.Zero, 0, IsOpaque: false, LocalSortCenter: new Vector3(0, 0, 10));
|
|
|
|
fx.Dispatcher.SubmitWalkAlphaInstance(in batch, Vector3.Zero, Matrix4x4.Identity);
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
fx.Dispatcher.SubmitWalkAlphaInstance(
|
|
in batch, Vector3.Zero, Matrix4x4.CreateTranslation(1, 0, 0)));
|
|
|
|
fx.AlphaQueue.AbortFrame();
|
|
}
|
|
|
|
// ── Deliverable 3: the ordered submitter's own cull scratch ────────────
|
|
|
|
[Fact]
|
|
public void PrepareThenDrawOrderedStream_DoesNotReadOrCorruptTheSharedAlphaCullScratch()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
using DrawScope draw = fx.BeginDraw();
|
|
|
|
// Poison the SHARED _drawCullModes scratch the alpha path owns — the
|
|
// exact array the ordered submitter used to write into before FW3.2a.
|
|
// Under the OLD shared-scratch behavior this test's second assertion
|
|
// fails: the ordered path's own command overwrites index 0 with
|
|
// its own cull mode (Clockwise), destroying the alpha path's poison.
|
|
FieldInfo field = typeof(WbDrawDispatcher).GetField(
|
|
"_drawCullModes", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
|
var poisonModes = (CullMode[])field.GetValue(fx.Dispatcher)!;
|
|
poisonModes[0] = CullMode.None;
|
|
|
|
var stream = new OrderedDrawStream();
|
|
stream.Append(new OrderedDrawCommand(
|
|
new GroupKey(0, 0, 3, new GpuTextureSlot(1), 0, TranslucencyKind.Opaque, FoliageFlags: 0, CullMode: CullMode.Clockwise),
|
|
Matrix4x4.Identity, WalkDrawStage.Terrain, 0, 0,
|
|
WbDrawDispatcher.InstanceLightSet.Disabled, 0, 1f, Vector2.Zero, 0));
|
|
|
|
fx.Dispatcher.PrepareOrderedStream(draw.Frame, stream, Matrix4x4.Identity);
|
|
fx.Dispatcher.DrawOrderedRange(draw.Pass, 0, stream.Count);
|
|
|
|
// (1) The ordered submission's OWN recorded cull call reflects the
|
|
// STREAM's cull mode (Clockwise -> GpuCullMode.Front), not the
|
|
// poisoned shared array's (None).
|
|
List<GpuCullMode> cullCalls = [.. fx.Device.Calls.OfType<GpuRecordedCullMode>().Select(c => c.CullMode)];
|
|
Assert.Equal([GpuCullMode.Front], cullCalls);
|
|
|
|
// (2) The shared _drawCullModes scratch is UNTOUCHED — the ordered
|
|
// path never wrote through it.
|
|
var afterModes = (CullMode[])field.GetValue(fx.Dispatcher)!;
|
|
Assert.Equal(CullMode.None, afterModes[0]);
|
|
}
|
|
|
|
// ── Fixture ─────────────────────────────────────────────────────────────
|
|
|
|
private readonly struct DrawScope : IDisposable
|
|
{
|
|
private readonly IDisposable _publication;
|
|
private readonly IGpuPassEncoder _pass;
|
|
|
|
public DrawScope(IGpuFrame frame, IGpuPassEncoder pass, IDisposable publication)
|
|
{
|
|
Frame = frame;
|
|
_pass = pass;
|
|
_publication = publication;
|
|
}
|
|
|
|
public IGpuFrame Frame { get; }
|
|
|
|
public IGpuPassEncoder Pass => _pass;
|
|
|
|
public void Dispose()
|
|
{
|
|
_publication.Dispose();
|
|
_pass.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class DispatcherFixture : IDisposable
|
|
{
|
|
private readonly WbMeshAdapter _meshAdapter;
|
|
private readonly TextureCache _textures;
|
|
|
|
public DispatcherFixture(
|
|
bool withAlphaQueue = false,
|
|
IRetailSelectionRenderSink? selectionSink = null)
|
|
{
|
|
Device = new RecordingGpuDevice();
|
|
FrameLifetime = new GpuDeviceFrameLifetime(Device);
|
|
Scope = new VulkanWorldPassScope(sampleCount: 1);
|
|
_textures = new TextureCache(Device, new NoopDatReaderWriter());
|
|
_meshAdapter = new WbMeshAdapter(
|
|
Device,
|
|
new NoopDatReaderWriter(),
|
|
new NullPreparedAssetSource(),
|
|
NullLogger<WbMeshAdapter>.Instance,
|
|
Device.Retirement);
|
|
var entitySpawnAdapter = new EntitySpawnAdapter(
|
|
_textures,
|
|
_ => throw new NotSupportedException("Not exercised by these tests."));
|
|
AlphaQueue = withAlphaQueue ? new RetailAlphaQueue() : null;
|
|
|
|
Dispatcher = new WbDrawDispatcher(
|
|
Device,
|
|
FrameLifetime,
|
|
Scope,
|
|
_textures,
|
|
_meshAdapter,
|
|
entitySpawnAdapter,
|
|
new EntityClassificationCache(),
|
|
new AcDream.Core.Rendering.TranslucencyFadeManager(),
|
|
selectionSink: selectionSink,
|
|
alphaQueue: AlphaQueue);
|
|
}
|
|
|
|
public RecordingGpuDevice Device { get; }
|
|
|
|
public GpuDeviceFrameLifetime FrameLifetime { get; }
|
|
|
|
public VulkanWorldPassScope Scope { get; }
|
|
|
|
public WbDrawDispatcher Dispatcher { get; }
|
|
|
|
public RetailAlphaQueue? AlphaQueue { get; }
|
|
|
|
public ObjectMeshManager Manager => _meshAdapter.MeshManager!;
|
|
|
|
public DrawScope BeginDraw()
|
|
{
|
|
FrameLifetime.BeginFrame();
|
|
IGpuFrame frame = FrameLifetime.CurrentFrame!;
|
|
IGpuPassEncoder pass = frame.BeginPass(
|
|
GpuPassDescription.BackbufferClear(
|
|
"fw3-2a-walk-populator-test", Vector4.Zero, sampleCount: 1));
|
|
IDisposable publication = Scope.Publish(pass);
|
|
Device.Clear();
|
|
return new DrawScope(frame, pass, publication);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Dispatcher.Dispose();
|
|
_meshAdapter.Dispose();
|
|
_textures.Dispose();
|
|
Device.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class NullPreparedAssetSource : IPreparedAssetSource
|
|
{
|
|
public PreparedAssetSourceStats Stats => default;
|
|
|
|
public CacheStats DecodedTextureCacheStats => default;
|
|
|
|
public PreparedAssetPresence Probe(
|
|
AcDream.Content.Pak.PakAssetType type,
|
|
uint sourceFileId) =>
|
|
PreparedAssetPresence.Missing;
|
|
|
|
public PreparedAssetReadResult Read(
|
|
in PreparedAssetRequest request,
|
|
CancellationToken cancellationToken = default) =>
|
|
PreparedAssetReadResult.Missing;
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
private sealed class NoopDatReaderWriter : IDatReaderWriter
|
|
{
|
|
private readonly StubDatabase _portal = new();
|
|
private readonly StubDatabase _highRes = new();
|
|
private readonly StubDatabase _language = new();
|
|
private readonly StubDatabase _cell = new();
|
|
|
|
public string SourceDirectory => string.Empty;
|
|
|
|
public IDatDatabase Portal => _portal;
|
|
|
|
public IDatDatabase Cell => _cell;
|
|
|
|
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
|
|
new(new Dictionary<uint, IDatDatabase>());
|
|
|
|
public IDatDatabase HighRes => _highRes;
|
|
|
|
public IDatDatabase Language => _language;
|
|
|
|
public IDatDatabase Local => _language;
|
|
|
|
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
|
|
new(new Dictionary<uint, uint>());
|
|
|
|
public int PortalIteration => 0;
|
|
|
|
public int CellIteration => 0;
|
|
|
|
public int HighResIteration => 0;
|
|
|
|
public int LanguageIteration => 0;
|
|
|
|
public bool TryGetFileBytes(
|
|
uint regionId,
|
|
uint fileId,
|
|
ref byte[] bytes,
|
|
out int bytesRead)
|
|
{
|
|
bytesRead = 0;
|
|
return false;
|
|
}
|
|
|
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
|
Array.Empty<uint>();
|
|
|
|
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
|
|
Array.Empty<IDatReaderWriter.IdResolution>();
|
|
|
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
public bool TrySave<T>(
|
|
uint regionId,
|
|
T obj,
|
|
int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
[return: MaybeNull]
|
|
public T Get<T>(uint fileId) where T : IDBObj => default;
|
|
|
|
public bool TryGet<T>(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
|
{
|
|
value = default;
|
|
return false;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
|
|
private sealed class StubDatabase : IDatDatabase
|
|
{
|
|
public DatDatabase Db => throw new NotSupportedException();
|
|
|
|
public int Iteration => 0;
|
|
|
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
|
Array.Empty<uint>();
|
|
|
|
public bool TryGet<T>(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
|
{
|
|
value = default;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetFileBytes(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out byte[] value)
|
|
{
|
|
value = null;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetFileBytes(
|
|
uint fileId,
|
|
ref byte[] bytes,
|
|
out int bytesRead)
|
|
{
|
|
bytesRead = 0;
|
|
return false;
|
|
}
|
|
|
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
}
|
|
}
|