Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
528 lines
21 KiB
C#
528 lines
21 KiB
C#
using System.Numerics;
|
||
using AcDream.Content;
|
||
using AcDream.App.Rendering;
|
||
using AcDream.App.UI;
|
||
using DatReaderWriter;
|
||
using Silk.NET.Input;
|
||
using Silk.NET.Maths;
|
||
using Silk.NET.OpenGL;
|
||
using Silk.NET.Windowing;
|
||
using SixLabors.ImageSharp;
|
||
using SixLabors.ImageSharp.PixelFormats;
|
||
using static Silk.NET.OpenGL.ClearBufferMask;
|
||
|
||
namespace AcDream.App.Studio;
|
||
|
||
/// <summary>
|
||
/// Standalone Silk.NET window that boots the production render stack
|
||
/// (<see cref="RenderBootstrap"/>) and previews a single UI panel
|
||
/// identified by a <see cref="LayoutSource"/>.
|
||
///
|
||
/// Usage: dotnet run -- ui-studio [dat-dir] [--layout 0xNNNN] [--markup path] [--mockup]
|
||
///
|
||
/// Task 3 adds an ImGui IDE on top of the panel FBO:
|
||
/// <list type="bullet">
|
||
/// <item>Canvas pane — the panel rendered off-screen via <see cref="PanelFbo"/>.</item>
|
||
/// <item>Tree pane — the element hierarchy; click-to-select.</item>
|
||
/// <item>Properties pane — geometry/anchors/flags of the selected element.</item>
|
||
/// <item>Click-to-inspect — a left-click in the canvas selects the topmost
|
||
/// element under the cursor via <see cref="UiRoot.Pick"/>.</item>
|
||
/// </list>
|
||
///
|
||
/// The window is intentionally thin: no game world, no physics, no streaming —
|
||
/// just GL + UiHost + the layout under test, identical to how the panel
|
||
/// appears inside <c>GameWindow</c>.
|
||
/// </summary>
|
||
public sealed class StudioWindow : IDisposable
|
||
{
|
||
private readonly StudioOptions _opts;
|
||
|
||
// Created in OnLoad, released in OnClosing.
|
||
private IWindow? _window;
|
||
private IDatReaderWriter? _dats;
|
||
private RenderStack? _stack;
|
||
private LayoutSource? _source;
|
||
|
||
// Task 3 additions.
|
||
private AcDream.UI.ImGui.ImGuiBootstrapper? _imgui;
|
||
private PanelFbo? _panelFbo;
|
||
private StudioInspector? _inspector;
|
||
private UiElement? _panelRoot; // top-level element added to UiRoot (for hit-test + tree)
|
||
|
||
// UX-pass additions: panel picker + current-slug tracking.
|
||
private string? _currentSlug; // slug of the panel currently displayed (null = non-dump mode)
|
||
private string? _dumpFile; // resolved dump file path (once, in OnLoad)
|
||
private IReadOnlyList<string> _dumpSlugs = Array.Empty<string>(); // all slugs from the dump
|
||
|
||
// Task 4: sample data table — built once in OnLoad and kept alive for the window's lifetime
|
||
// so the controller subscriptions (ObjectAdded/ObjectMoved etc.) fire correctly.
|
||
private AcDream.Core.Items.ClientObjectTable? _objects;
|
||
private IRetainedPanelController? _fixtureController;
|
||
|
||
// Headless screenshot mode: set when --screenshot was passed.
|
||
// True after the first OnRender fires the screenshot (guard against repeat).
|
||
private bool _screenshotDone;
|
||
|
||
public StudioWindow(StudioOptions opts)
|
||
{
|
||
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Open the window and block until it is closed.
|
||
/// Mirrors <c>GameWindow.Run()</c>.
|
||
/// </summary>
|
||
public void Run()
|
||
{
|
||
// Resolve quality settings the same way GameWindow.Run() does
|
||
// (SettingsStore → QualitySettings.From → WithEnvOverrides).
|
||
var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
|
||
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
|
||
var startupDisplay = startupStore.LoadDisplay();
|
||
var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality);
|
||
var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase);
|
||
|
||
var options = WindowOptions.Default with
|
||
{
|
||
Size = new Vector2D<int>(1280, 720),
|
||
Title = "acdream UI Studio",
|
||
API = new GraphicsAPI(
|
||
ContextAPI.OpenGL,
|
||
ContextProfile.Core,
|
||
ContextFlags.ForwardCompatible,
|
||
new APIVersion(4, 3)),
|
||
VSync = false,
|
||
// MSAA from quality preset — must be baked into the GL context at creation.
|
||
Samples = startupQuality.MsaaSamples,
|
||
PreferredStencilBufferBits = 8,
|
||
// Headless screenshot mode: hide the window so no desktop flash occurs.
|
||
// The GL context is still fully valid on a hidden window; FBO rendering
|
||
// is off-screen and independent of window visibility.
|
||
IsVisible = _opts.ScreenshotPath is null,
|
||
};
|
||
|
||
_window = Window.Create(options);
|
||
_window.Load += OnLoad;
|
||
_window.Update += OnUpdate;
|
||
_window.Render += OnRender;
|
||
_window.Closing += OnClosing;
|
||
_window.Run();
|
||
}
|
||
|
||
private void OnLoad()
|
||
{
|
||
var gl = GL.GetApi(_window!);
|
||
|
||
_dats = RuntimeDatCollectionFactory.OpenReadOnly(_opts.DatDir);
|
||
|
||
// Build QualitySettings for RenderBootstrap (same as Run() above — re-read
|
||
// after the GL context is confirmed, mirroring GameWindow.OnLoad).
|
||
var store = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
|
||
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
|
||
var display = store.LoadDisplay();
|
||
var quality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(
|
||
AcDream.UI.Abstractions.Settings.QualitySettings.From(display.Quality));
|
||
|
||
_stack = RenderBootstrap.Create(gl, _dats, new RenderBootstrapOptions(quality));
|
||
|
||
if (_opts.Mockup)
|
||
MockupDesktop.Load(_dats, _stack);
|
||
|
||
// Load the panel described by options and add it to the UI tree.
|
||
// Task 4b: --dump <slug> uses DumpLayout (static retail mockup, no controllers).
|
||
// All other modes use LayoutSource + FixtureProvider (production path).
|
||
//
|
||
// Fix C: pass the per-element font resolver into LayoutSource so that elements
|
||
// with a non-zero FontDid get their own dat font at build time. This is wired
|
||
// ONLY in the studio path; GameWindow's Import calls continue to pass null so the
|
||
// live game path is provably unchanged (follow-up: GameWindow font-resolver wire-up).
|
||
_source = new LayoutSource(_dats, _stack.ResolveChrome, _stack.VitalsDatFont,
|
||
fontResolve: _stack.ResolveDatFont);
|
||
|
||
// Resolve the dump file once (used by OnLoad + by LoadDumpPanel at runtime).
|
||
_dumpFile = _opts.ResolveDumpFile();
|
||
if (_dumpFile is not null)
|
||
_dumpSlugs = UiDumpModel.ListSlugs(_dumpFile)
|
||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList();
|
||
|
||
UiElement? root = null;
|
||
if (!_opts.Mockup && _opts.DumpSlug is not null)
|
||
{
|
||
if (_dumpFile is null)
|
||
{
|
||
Console.Error.WriteLine("[studio] --dump: retail UI dump file not found. " +
|
||
"Expected docs/research/2026-06-25-retail-ui-layout-dump.json in the source tree, " +
|
||
"or pass --dump-file <path>.");
|
||
root = null;
|
||
}
|
||
else
|
||
{
|
||
root = DumpLayout.Load(_dumpFile, _opts.DumpSlug, _stack.ResolveChrome, out var dumpErr);
|
||
if (root is null)
|
||
Console.Error.WriteLine($"[studio] dump load failed: {dumpErr}");
|
||
else
|
||
_currentSlug = _opts.DumpSlug;
|
||
}
|
||
}
|
||
else if (!_opts.Mockup)
|
||
{
|
||
root = _source.Load(_opts);
|
||
if (root is null)
|
||
Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}");
|
||
else
|
||
NormalizeSinglePanelRoot(root);
|
||
}
|
||
|
||
_panelRoot = root;
|
||
if (root is not null)
|
||
{
|
||
_stack.UiHost.Root.AddChild(root);
|
||
|
||
// Task 4: populate the panel with sample data via production controllers,
|
||
// so inventory / vitals / toolbar panels render with plausible content.
|
||
// Dump source is static — no FixtureProvider needed.
|
||
if (_opts.DumpSlug is null && _source.CurrentLayout is not null)
|
||
{
|
||
uint layoutId = _opts.LayoutId ?? 0x2100006Cu;
|
||
_objects = SampleData.BuildObjectTable();
|
||
_fixtureController = FixtureProvider.Populate(
|
||
layoutId, _source.CurrentLayout, _stack, _objects, _dats);
|
||
}
|
||
}
|
||
|
||
// Task 3: ImGui IDE — interactive mode only.
|
||
// Headless screenshot mode needs only PanelFbo; ImGui/inspector/input are skipped.
|
||
_panelFbo = new PanelFbo(gl);
|
||
if (_opts.ScreenshotPath is null)
|
||
{
|
||
var input = _window!.CreateInput();
|
||
// Mockup mode draws UiHost directly to the window, so raw Silk mouse coordinates
|
||
// already match UI pixels. Inspector mode draws UiHost inside an ImGui canvas, so
|
||
// mouse is forwarded manually from DrawCanvas with canvas-local remapping.
|
||
if (_opts.Mockup)
|
||
foreach (var mouse in input.Mice)
|
||
_stack.UiHost.WireMouse(mouse);
|
||
foreach (var kb in input.Keyboards)
|
||
_stack.UiHost.WireKeyboard(kb);
|
||
|
||
if (_opts.Mockup)
|
||
return;
|
||
|
||
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input);
|
||
_inspector = new StudioInspector();
|
||
}
|
||
}
|
||
|
||
private void OnUpdate(double dt) { }
|
||
|
||
private void OnRender(double dt)
|
||
{
|
||
if (_stack is null || _panelFbo is null) return;
|
||
_stack.BeginFrame();
|
||
Exception? renderFailure = null;
|
||
try
|
||
{
|
||
|
||
// ── HEADLESS SCREENSHOT PATH ──────────────────────────────────────────────
|
||
if (_opts.ScreenshotPath is not null)
|
||
{
|
||
if (_screenshotDone) return; // fire exactly once
|
||
_screenshotDone = true;
|
||
|
||
// Pick render size from the loaded root's bounds (clamped to sane limits).
|
||
// Fall back to 1280×720 when the root has no explicit size.
|
||
int w = 1280, h = 720;
|
||
if (!_opts.Mockup && _panelRoot is not null)
|
||
{
|
||
float rw = _panelRoot.Width;
|
||
float rh = _panelRoot.Height;
|
||
if (rw >= 1f && rh >= 1f)
|
||
{
|
||
w = Math.Clamp((int)rw, 256, 2048);
|
||
h = Math.Clamp((int)rh, 256, 2048);
|
||
}
|
||
}
|
||
|
||
// Tick once so widget state is initialised (e.g. bar fills).
|
||
_stack.UiHost.Tick(dt);
|
||
|
||
// Render the panel into the FBO.
|
||
_panelFbo.Render(w, h, _stack.UiHost);
|
||
|
||
// Read back RGBA pixels (FBO origin = bottom-left).
|
||
byte[] pixels = _panelFbo.ReadColorRgba(w, h);
|
||
if (pixels.Length == 0)
|
||
{
|
||
Console.Error.WriteLine("[studio-screenshot] FBO readback returned no pixels.");
|
||
_window?.Close();
|
||
return;
|
||
}
|
||
|
||
// Flip rows vertically: FBO bottom-left → PNG top-left.
|
||
int stride = w * 4;
|
||
byte[] flipped = new byte[pixels.Length];
|
||
for (int row = 0; row < h; row++)
|
||
{
|
||
System.Buffer.BlockCopy(pixels, row * stride, flipped, (h - 1 - row) * stride, stride);
|
||
}
|
||
|
||
// Build ImageSharp image and save as PNG.
|
||
var path = _opts.ScreenshotPath;
|
||
var dir = Path.GetDirectoryName(path);
|
||
if (!string.IsNullOrEmpty(dir))
|
||
Directory.CreateDirectory(dir);
|
||
|
||
using var img = Image.LoadPixelData<Rgba32>(flipped, w, h);
|
||
img.SaveAsPng(path);
|
||
Console.WriteLine($"[studio-screenshot] wrote {path} ({w}x{h})");
|
||
|
||
_window?.Close();
|
||
return;
|
||
}
|
||
|
||
// ── INTERACTIVE PATH ──────────────────────────────────────────────────────
|
||
if (_opts.Mockup)
|
||
{
|
||
var mockupGl = _stack.Gl;
|
||
int mockupW = _window!.Size.X;
|
||
int mockupH = _window!.Size.Y;
|
||
_stack.UiHost.Tick(dt);
|
||
mockupGl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
|
||
mockupGl.Viewport(0, 0, (uint)mockupW, (uint)mockupH);
|
||
mockupGl.ClearColor(0.08f, 0.08f, 0.08f, 1f);
|
||
mockupGl.Clear(ColorBufferBit | DepthBufferBit);
|
||
_stack.UiHost.Draw(new Vector2(mockupW, mockupH));
|
||
return;
|
||
}
|
||
|
||
if (_imgui is null || _inspector is null) return;
|
||
|
||
var gl = _stack.Gl;
|
||
int iw = _window!.Size.X;
|
||
int ih = _window!.Size.Y;
|
||
|
||
// 1. Tick the UI widgets (OnRender's own dt — Update + Render fire with the same delta).
|
||
_stack.UiHost.Tick(dt);
|
||
|
||
// 2. Render the panel into the off-screen FBO; get the color texture.
|
||
// The FBO is the same logical size as the window, so element rects map 1:1 to
|
||
// FBO pixels — no scale factor needed when displaying the canvas at full size.
|
||
uint panelTex = _panelFbo.Render(iw, ih, _stack.UiHost);
|
||
|
||
// 3. Clear the window back-buffer (the dark ImGui background shows behind panes).
|
||
gl.ClearColor(0.1f, 0.1f, 0.1f, 1f);
|
||
gl.Clear(ColorBufferBit | DepthBufferBit);
|
||
|
||
// 4. Begin the ImGui frame.
|
||
_imgui.BeginFrame((float)dt);
|
||
|
||
// ── Layout constants (fixed pane arrangement, FirstUseEver) ──────────────
|
||
// MenuBar: always-on-top main menu bar (~22px) — panel picker lives here so it
|
||
// is never covered by the floating panes (replaces the old 40px toolbar).
|
||
// Tree: 280px wide on the left, below menu bar.
|
||
// Canvas: centre strip between tree and properties.
|
||
// Props: 340px wide on the right, below menu bar.
|
||
const int kMenuBarH = 22; // ImGui default main menu bar height
|
||
const int kTreeW = 280;
|
||
const int kPropsW = 340;
|
||
int canvasX = kTreeW;
|
||
int canvasW = Math.Max(1, iw - kTreeW - kPropsW);
|
||
int propsX = iw - kPropsW;
|
||
int paneY = kMenuBarH;
|
||
int paneH = Math.Max(1, ih - kMenuBarH);
|
||
|
||
// 5. Main menu bar — panel picker combo pinned to the window top.
|
||
// BeginMainMenuBar returns true when the bar is visible (always is); the combo
|
||
// inside it is always-on-top and is never occluded by Tree/Canvas/Props panes.
|
||
string? pickedSlug = null;
|
||
if (ImGuiNET.ImGui.BeginMainMenuBar())
|
||
{
|
||
ImGuiNET.ImGui.SetNextItemWidth(300f);
|
||
string preview = _currentSlug ?? "(none)";
|
||
if (ImGuiNET.ImGui.BeginCombo("Panel", preview))
|
||
{
|
||
foreach (var slug in _dumpSlugs)
|
||
{
|
||
bool selected = string.Equals(slug, _currentSlug,
|
||
System.StringComparison.OrdinalIgnoreCase);
|
||
if (ImGuiNET.ImGui.Selectable(slug, selected) && !selected)
|
||
pickedSlug = slug;
|
||
if (selected)
|
||
ImGuiNET.ImGui.SetItemDefaultFocus();
|
||
}
|
||
ImGuiNET.ImGui.EndCombo();
|
||
}
|
||
ImGuiNET.ImGui.EndMainMenuBar();
|
||
}
|
||
if (pickedSlug is not null)
|
||
LoadDumpPanel(pickedSlug);
|
||
|
||
// 6. Canvas pane — show the FBO texture; gather canvas mouse events.
|
||
var canvasEvt = default(CanvasInputEvent);
|
||
if (panelTex != 0)
|
||
canvasEvt = _inspector.DrawCanvas(
|
||
(nint)panelTex, iw, ih,
|
||
canvasX, canvasW, paneY, paneH);
|
||
|
||
// 7. Forward canvas mouse events to the panel UiHost or the inspector tree.
|
||
//
|
||
// Coordinate mapping (see StudioInspector.DrawCanvas summary):
|
||
// panel-local pixel = raw_mouse - ImGui.GetItemRectMin() (1:1 scale, no Y flip)
|
||
// The image is drawn V-flipped (uv0.Y=1, uv1.Y=0) so screen top = panel Y=0.
|
||
//
|
||
// Interact mode (default): canvas mouse events go directly to UiRoot so elements respond.
|
||
// OnMouseMove + OnMouseDown/Up + OnScroll are all forwarded.
|
||
// A Console.WriteLine confirms each forwarded left-click for live verification.
|
||
//
|
||
// Inspect mode: left-click hit-tests and selects the element in the tree (old behavior).
|
||
// OnMouseMove is still forwarded so hover/tooltip state in the panel stays live.
|
||
if (canvasEvt.IsHovered)
|
||
{
|
||
int mx = canvasEvt.MouseX;
|
||
int my = canvasEvt.MouseY;
|
||
var root = _stack.UiHost.Root;
|
||
|
||
// Always forward mouse-move so hover highlights / tooltips in the panel work.
|
||
root.OnMouseMove(mx, my);
|
||
|
||
if (_inspector.InteractMode)
|
||
{
|
||
// ── Interact: live panel interaction ──────────────────────────────
|
||
if (canvasEvt.LeftDown)
|
||
{
|
||
Console.WriteLine($"[studio] canvas click → panel ({mx}, {my})");
|
||
root.OnMouseDown(UiMouseButton.Left, mx, my);
|
||
}
|
||
if (canvasEvt.LeftUp)
|
||
root.OnMouseUp(UiMouseButton.Left, mx, my);
|
||
if (canvasEvt.ScrollDelta != 0)
|
||
root.OnScroll(canvasEvt.ScrollDelta);
|
||
}
|
||
else
|
||
{
|
||
// ── Inspect: click selects an element in the tree ─────────────────
|
||
if (canvasEvt.LeftDown)
|
||
{
|
||
var hit = root.Pick(mx, my);
|
||
if (hit is not null)
|
||
_inspector.Selected = hit;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 8. Element tree pane.
|
||
if (_panelRoot is not null)
|
||
_inspector.DrawTree(_panelRoot, 0, paneY, kTreeW, paneH);
|
||
|
||
// 9. Properties pane.
|
||
_inspector.DrawProperties(propsX, paneY, kPropsW, paneH);
|
||
|
||
// 9. Finalise ImGui and flush draw data to the window.
|
||
_imgui.Render();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
renderFailure = ex;
|
||
throw;
|
||
}
|
||
finally
|
||
{
|
||
try
|
||
{
|
||
_stack.EndFrame();
|
||
}
|
||
catch (Exception closeFailure) when (renderFailure is not null)
|
||
{
|
||
throw new AggregateException(
|
||
"UI Studio rendering failed and its GPU frame could not be closed.",
|
||
renderFailure,
|
||
closeFailure);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Load a different dump panel at runtime (no relaunch required).
|
||
/// Removes the current <see cref="_panelRoot"/> from the UI tree,
|
||
/// loads the named slug from the dump, and installs the new root.
|
||
/// Resets <see cref="StudioInspector.Selected"/> to null.
|
||
/// No-op when the dump file is not available or the slug fails to load.
|
||
/// </summary>
|
||
public void LoadDumpPanel(string slug)
|
||
{
|
||
if (_stack is null || _inspector is null) return;
|
||
if (_dumpFile is null)
|
||
{
|
||
Console.Error.WriteLine("[studio] LoadDumpPanel: dump file not available.");
|
||
return;
|
||
}
|
||
|
||
// Remove the existing panel root from the tree. Fixture controllers
|
||
// own subscriptions into the sample table and end with their panel.
|
||
_fixtureController?.Dispose();
|
||
_fixtureController = null;
|
||
if (_panelRoot is not null)
|
||
{
|
||
_stack.UiHost.Root.RemoveChild(_panelRoot);
|
||
_panelRoot = null;
|
||
}
|
||
|
||
// Load the new panel.
|
||
var newRoot = DumpLayout.Load(_dumpFile, slug, _stack.ResolveChrome, out var err);
|
||
if (newRoot is null)
|
||
{
|
||
Console.Error.WriteLine($"[studio] LoadDumpPanel('{slug}') failed: {err}");
|
||
_currentSlug = null;
|
||
return;
|
||
}
|
||
|
||
_stack.UiHost.Root.AddChild(newRoot);
|
||
_panelRoot = newRoot;
|
||
_currentSlug = slug;
|
||
_inspector.Selected = null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Studio dat-layout previews show one panel in isolation, not at its live-game
|
||
/// screen position. Some top-level layouts (inventory: x=500,y=138) otherwise
|
||
/// draw entirely outside the root-sized screenshot FBO.
|
||
/// </summary>
|
||
internal static void NormalizeSinglePanelRoot(UiElement root)
|
||
{
|
||
root.Left = 0f;
|
||
root.Top = 0f;
|
||
root.Anchors = AnchorEdges.None;
|
||
}
|
||
|
||
private void OnClosing()
|
||
{
|
||
_fixtureController?.Dispose();
|
||
_fixtureController = null;
|
||
_imgui?.Dispose();
|
||
_panelFbo?.Dispose();
|
||
_imgui = null;
|
||
_panelFbo = null;
|
||
_stack?.Dispose(); // whole render stack: dispatcher/mesh/textures/shader/ubo/uihost
|
||
_dats?.Dispose();
|
||
_dats = null;
|
||
_stack = null;
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
_fixtureController?.Dispose();
|
||
_fixtureController = null;
|
||
_imgui?.Dispose();
|
||
_panelFbo?.Dispose();
|
||
_imgui = null;
|
||
_panelFbo = null;
|
||
_window?.Dispose();
|
||
_window = null;
|
||
// If OnClosing wasn't called (e.g. an exception before Run() completed), dispose the FULL
|
||
// stack anyway — the review flagged that disposing only UiHost here leaked the rest.
|
||
_stack?.Dispose();
|
||
_dats?.Dispose();
|
||
_dats = null;
|
||
_stack = null;
|
||
}
|
||
}
|