fix(G.3): pre-collapse dungeon streaming at login/teleport — kill the login FPS ramp (#135)

On login (or teleport) into a dungeon, FPS started ~10 and climbed over ~30 s.
Root cause: the dungeon "collapse" (which shrinks the 25x25 streaming window to
the player's single dungeon landblock — AC dungeons have no neighbours) only
fires once the per-frame `insideDungeon` gate reads true, and that gate keys on
the physics CurrCell, which isn't set until the player is PLACED, which waits for
the dungeon landblock to hydrate. So during the whole hydration window NormalTick
bootstraps the full window — ~24 unrelated ocean-grid neighbour dungeons + their
~19k entities each — and the collapse only mops them up afterward. That mop-up is
the ramp.

Fix: trigger the SAME collapse early, the instant we recenter the streaming center
onto a sealed dungeon cell, before the first NormalTick.

- StreamingController.PreCollapseToDungeon(cx,cy): fires EnterDungeonCollapse
  early (idempotent). The expensive neighbour window is never enqueued.
- GameWindow.IsSealedDungeonCell(cellId): reads the EnvCell dat SeenOutside flag
  (CurrCell is null pre-placement) — the same flag ObjCell.SeenOutside and the
  per-frame gate use, so the early decision matches the eventual one. Distinguishes
  a real dungeon from a cottage/inn interior (SeenOutside → keeps its outdoor
  surround). Excludes the 0xFFFE/0xFFFF structural shell ids so an outdoor spawn id
  can't type-confuse a LandBlock record as an EnvCell.
- Hooks: OnLiveEntitySpawnedLocked (login) + OnLivePositionUpdated (teleport).
- Observer robustness: during a teleport PortalSpace hold the streaming observer
  follows the recentered destination, not the frozen pre-teleport position (which
  could drift >=2 landblocks off and trip ExitDungeonExpand). And
  _lastLivePlayerLandblockId is now filtered to the player guid (resolves the
  Phase A.1 TODO) so a stray NPC UpdatePosition can't drift the login-hold observer
  off the dungeon.

Faithful EARLY trigger of the existing AP-36 collapse mechanism, not a new
workaround — AP-36 amended in the same commit. Adversarially reviewed across
timing / threading / faithfulness lenses; 5 new tests including the real runtime
ordering (Tick bootstraps, then PreCollapse cancels). Core suite green (1463).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-14 16:46:56 +02:00
parent a100bc37a7
commit 712f17f0f2
5 changed files with 231 additions and 7 deletions

View file

@ -48,11 +48,27 @@ Copy this block when adding a new issue:
## #135 — ~30 s low-FPS ramp at login (≈10 fps → high) before streaming settles
**Status:** OPEN
**Status:** FIX LANDED — pending visual gate (login into the 0x0007 dungeon → FPS steady in ~12 s, no neighbour load/unload churn)
**Severity:** LOW (startup-only; self-corrects)
**Filed:** 2026-06-14
**Component:** streaming — first-frame bootstrap vs the dungeon collapse
**FIX (2026-06-14):** pre-collapse streaming the instant we recenter onto a SEALED
dungeon cell at login/teleport, before the first `NormalTick` bootstraps the window.
- `StreamingController.PreCollapseToDungeon(cx,cy)` — fires the existing `EnterDungeonCollapse`
early (idempotent), so the expensive ocean-grid neighbour window is never enqueued
(teleport) / is enqueued-then-immediately-cleared for a cheap Holtburg frame (login).
- `GameWindow.IsSealedDungeonCell(cellId)` — reads the `EnvCell` dat `SeenOutside` flag
(the same flag the hydrated `ObjCell.SeenOutside` + the per-frame gate use) so a cottage/inn
interior keeps its outdoor surround; excludes the 0xFFFE/0xFFFF shell ids.
- Hooks in `OnLiveEntitySpawnedLocked` (login) + `OnLivePositionUpdated` (teleport).
- Observer robustness: during a teleport `PortalSpace` hold the observer follows the
recentered destination (not the frozen position); `_lastLivePlayerLandblockId` is now
filtered to the player guid (resolving a Phase A.1 TODO) so a stray NPC update can't drift
the login-hold observer off the dungeon and trip `ExitDungeonExpand`.
Adversarially reviewed (3 lenses); register row AP-36 amended. Tests in
`StreamingControllerDungeonGateTests` (5 new, incl. the real Tick-then-PreCollapse ordering).
**Description:** On login into a dungeon, FPS starts ~10 and climbs over ~30 s before
settling (then 1000+ fps). User: "we still have about 30ish seconds before FPS is ramped
up; when logging in I get like 10 then it slowly increases."

View file

@ -130,7 +130,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock | `src/AcDream.App/Rendering/GameWindow.cs:6895` (predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 |
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 |
| AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ 0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 |
---

View file

@ -2471,6 +2471,23 @@ public sealed class GameWindow : IDisposable
_liveCenterY = lbY;
}
// #135: the instant we know the player spawned into a SEALED dungeon,
// pre-collapse streaming to that single landblock — BEFORE the first
// StreamingController.Tick bootstraps the 25×25 ocean-grid window. The
// player isn't placed yet (physics CurrCell is null), so the per-frame
// insideDungeon gate stays false for the entire hydration window and
// NormalTick would otherwise load ~24 neighbor dungeons then unload them
// (the login FPS ramp the user reported — 10 fps slowly climbing). Sealed-
// dungeon only: a cottage/inn interior (SeenOutside) keeps its outdoor
// surround. We hold _datLock here, and IsSealedDungeonCell re-takes it
// (reentrant); the controller call is render-thread-safe (Channel writes).
if (spawn.Guid == _playerServerGuid
&& _streamingController is not null
&& IsSealedDungeonCell(p.LandblockId))
{
_streamingController.PreCollapseToDungeon(lbX, lbY);
}
var origin = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
@ -4484,10 +4501,18 @@ public sealed class GameWindow : IDisposable
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{
// Phase A.1: track the most recently updated entity's landblock so the
// streaming controller can follow the player. TODO: filter by our own
// character guid once we reliably know it from CharacterList.
_lastLivePlayerLandblockId = update.Position.LandblockId;
// Phase A.1 / #135: track the PLAYER's last server-known landblock so the
// streaming controller can follow the player in the fly-camera / pre-player-mode
// (login hold) views. Filtered to our OWN character guid — resolving the original
// Phase A.1 TODO. An arbitrary NPC's UpdatePosition from a far outdoor landblock
// must NOT move the streaming observer: during a dungeon-login hold (player not
// yet placed, so _playerController is null and the PortalSpace observer branch
// can't apply) that would drift the observer off the pre-collapsed dungeon
// landblock and trip ExitDungeonExpand, re-streaming the 25×25 neighbor window
// the pre-collapse just suppressed. _playerServerGuid is set from CharacterList
// (~line 1984) before world entry, so it is valid by the time updates arrive.
if (update.Guid == _playerServerGuid)
_lastLivePlayerLandblockId = update.Position.LandblockId;
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
@ -4936,6 +4961,15 @@ public sealed class GameWindow : IDisposable
_liveCenterX = lbX;
_liveCenterY = lbY;
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
// #135: pre-collapse on teleport into a sealed dungeon too — same
// race as login. The destination isn't placed until it hydrates, so
// without this NormalTick loads the full neighbor window during the
// arrival hold. The PortalSpace observer branch (OnUpdate) keeps the
// observer pinned to _liveCenterX/Y while held, so the stale frozen
// player position can't drift the observer off the dungeon and re-expand.
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
_streamingController.PreCollapseToDungeon(lbX, lbY);
}
else
{
@ -6863,7 +6897,27 @@ public sealed class GameWindow : IDisposable
int observerCx = _liveCenterX;
int observerCy = _liveCenterY;
if (_playerMode && _playerController is not null)
if (_playerMode && _playerController is not null
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace)
{
// Teleport hold (#135): the local player position is frozen at the
// PRE-teleport spot, expressed in the OLD center frame, but
// _liveCenterX/_liveCenterY were already recentered onto the
// destination landblock (OnLivePositionUpdated). Follow the
// destination directly — the stale position-derived offset
// (_liveCenterX + floor(frozenPos/192)) could land ≥2 landblocks off
// the dungeon and trip ExitDungeonExpand, re-streaming the very
// neighbor window the pre-collapse just suppressed. Correct for an
// outdoor teleport too: pre-load the destination during the hold.
//
// NOTE: these assignments equal the observerCx/Cy defaults initialized
// above — the LOAD-BEARING effect of this branch is INHIBITING the
// position-derived offset in the else-if below while the player position
// is frozen, not the (redundant) assignment. Kept explicit for clarity.
observerCx = _liveCenterX;
observerCy = _liveCenterY;
}
else if (_playerMode && _playerController is not null)
{
// Player mode: follow the physics-resolved player position.
// The player walks via the local physics engine; the server
@ -11894,6 +11948,35 @@ public sealed class GameWindow : IDisposable
return unhydratable;
}
// #135: is this server-sent cell id a SEALED dungeon EnvCell — an indoor cell
// (low 16 bits >= 0x0100) whose EnvCell dat flags lack SeenOutside? Distinguishes
// a real dungeon (collapse streaming to its single landblock) from a building
// interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from
// an outdoor cell, WITHOUT needing the cell hydrated. Reads the SAME dat flag as
// the hydration path (BuildLoadedCell, ~line 5999) and as the physics
// CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse
// decision matches the eventual gate decision exactly. Returns false when the dat
// lacks the cell (out-of-range index / missing record) so we never collapse on a
// guess. The dat read is reentrant-safe under _datLock (Monitor) — callers may
// already hold it (the login spawn handler does).
private bool IsSealedDungeonCell(uint cellId)
{
// Not an EnvCell: the sub-0x0100 outdoor sub-cells AND the 0xFFFE/0xFFFF
// structural shell ids (LandBlockInfo / LandBlock heightmap). A naive
// `< 0x0100` test MISSES 0xFFFF (65535 is not < 256), and Get<EnvCell> on
// 0xXXYYFFFF would then type-confuse the LandBlock record living at that id as
// an EnvCell (its bytes unpack to a bogus Flags value). A real spawn/teleport
// position never carries a shell id, but exclude them so the read is sound.
uint low = cellId & 0xFFFFu;
if (low < 0x0100u || low >= 0xFFFEu) return false;
if (_dats is null) return false;
DatReaderWriter.DBObjs.EnvCell? envCell;
lock (_datLock)
envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(cellId);
return envCell is not null
&& !envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside);
}
private void EnterPlayerModeFromAutoEntry()
{
_playerMode = true;

View file

@ -149,6 +149,37 @@ public sealed class StreamingController
DrainAndApply();
}
/// <summary>
/// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first
/// <see cref="Tick"/> has a chance to bootstrap the full 25×25 window. Called
/// from the login / teleport spawn path the instant the streaming center is
/// recentered onto a SEALED dungeon landblock.
///
/// <para>The per-frame <c>insideDungeon</c> gate keys on the physics
/// <c>CurrCell</c>, which is only set once the player is PLACED — and placement
/// waits for the dungeon landblock to hydrate. So for the whole hydration window
/// (tens of seconds for a ~200-cell dungeon) the gate reads false and
/// <see cref="NormalTick"/> would enqueue the ~24 unrelated ocean-grid neighbor
/// dungeons (+ ~19k entities each); the collapse then only mops them up after
/// placement. That mop-up is the 10→high FPS ramp users see at a dungeon login.</para>
///
/// <para>Pre-collapsing means the EXPENSIVE dungeon-neighbour window is never
/// enqueued. On teleport nothing is enqueued at all (this fires before the next
/// Tick recenters). On login a brief Holtburg outdoor window may be enqueued by the
/// frame-1 NormalTick (before the player's spawn arrives) and is immediately
/// cancelled by <c>_clearPendingLoads</c> here — cheap outdoor terrain, not the
/// ocean-grid dungeons, and a handful of already-dequeued loads get swept next
/// frame. Idempotent: a no-op when already collapsed onto this same landblock, so a
/// re-sent spawn or a same-frame double call costs nothing. Render-thread only,
/// same as <see cref="Tick"/>.</para>
/// </summary>
public void PreCollapseToDungeon(int cx, int cy)
{
uint centerId = StreamingRegion.EncodeLandblockId(cx, cy);
if (_collapsed && _collapsedCenter == centerId) return;
EnterDungeonCollapse(cx, cy, centerId);
}
/// <summary>
/// Outdoor / building-interior streaming — the original two-tier model.
/// </summary>

View file

@ -147,6 +147,100 @@ public class StreamingControllerDungeonGateTests
Assert.Contains(Encode(0, 7), h.Unloads); // stale dungeon block, outside new window
}
[Fact]
public void PreCollapse_BeforeAnyTick_LoadsOnlyDungeon_NeverBootstrapsWindow()
{
// #135: at a dungeon login/teleport we pre-collapse the instant we recenter,
// BEFORE the first Tick. The full 25×25 neighbor window must NEVER be enqueued
// — only the single dungeon landblock loads.
var h = Make(); // empty state — nothing resident, _region is null
h.Ctrl.PreCollapseToDungeon(0, 7);
Assert.Single(h.Loads); // exactly one load
Assert.Equal(Encode(0, 7), h.Loads[0].Id); // the dungeon landblock
Assert.Equal(LandblockStreamJobKind.LoadNear, h.Loads[0].Kind);
Assert.DoesNotContain(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar);
}
[Fact]
public void PreCollapse_AfterBootstrapTick_CancelsWindow_UnloadsResidentNeighbors_KeepsDungeon()
{
// The REAL runtime ordering at a dungeon login: the per-frame streaming Tick
// runs FIRST and bootstraps the full 25×25 window, THEN the spawn handler fires
// PreCollapseToDungeon. The pre-collapse must cancel the queued window loads
// (_clearPendingLoads) and unload any neighbor that already finished streaming.
var h = Make();
h.Ctrl.Tick(0, 7, insideDungeon: false); // frame 1: NormalTick bootstraps the window
Assert.True(h.Loads.Count > 1); // the full window was enqueued
// Simulate neighbor landblocks that finished loading during the bootstrap,
// before the collapse edge.
h.State.AddLandblock(MakeLb(0, 7)); // the dungeon landblock itself
h.State.AddLandblock(MakeLb(0, 8)); // a neighbor ocean dungeon that loaded
h.State.AddLandblock(MakeLb(1, 7)); // another neighbor
h.Loads.Clear();
h.Unloads.Clear();
h.Ctrl.PreCollapseToDungeon(0, 7);
Assert.Equal(1, h.ClearCalls()); // queued window loads cancelled
Assert.Contains(Encode(0, 8), h.Unloads); // resident neighbor unloaded
Assert.Contains(Encode(1, 7), h.Unloads);
Assert.DoesNotContain(Encode(0, 7), h.Unloads); // dungeon landblock kept
}
[Fact]
public void PreCollapse_ThenHoldTicksWithStaleObserver_StaysCollapsed()
{
// After pre-collapse the player is held (CurrCell still null → insideDungeon
// false) while the dungeon hydrates. A stale observer that is the SAME dungeon
// landblock must keep streaming collapsed — no full-window reload.
var h = Make();
h.Ctrl.PreCollapseToDungeon(0, 7);
h.Loads.Clear();
h.Unloads.Clear();
h.Ctrl.Tick(0, 7, insideDungeon: false); // hold frame: not placed yet
Assert.Empty(h.Loads); // no neighbor window
Assert.Empty(h.Unloads);
}
[Fact]
public void PreCollapse_IsIdempotent_OnSameLandblock()
{
// A re-sent player spawn / a same-frame double call must not re-clear or
// re-enqueue.
var h = Make();
h.Ctrl.PreCollapseToDungeon(0, 7);
h.Loads.Clear();
h.Ctrl.PreCollapseToDungeon(0, 7);
Assert.Equal(1, h.ClearCalls()); // clear fired only on the first collapse
Assert.Empty(h.Loads); // no second dungeon load
}
[Fact]
public void PreCollapse_ThenPlaced_InsideDungeonTick_StaysCollapsed()
{
// When placement finally fires, the per-frame Tick(insideDungeon: true) sees
// the same collapsed landblock and holds — no re-collapse churn.
var h = Make();
h.State.AddLandblock(MakeLb(0, 7)); // dungeon landblock finished loading
h.Ctrl.PreCollapseToDungeon(0, 7);
h.Loads.Clear();
h.Unloads.Clear();
h.Ctrl.Tick(0, 7, insideDungeon: true); // placed: gate now fires
Assert.Equal(1, h.ClearCalls()); // no second clear
Assert.Empty(h.Loads);
Assert.DoesNotContain(Encode(0, 7), h.Unloads);
}
[Fact]
public void NormalOutdoorTick_Unchanged_NoCollapseNoClear()
{