Skip to content

fix(ui): let SDK surfaces outlive scenes and keep HUDs clear of the game - #252

Open
Furroxide wants to merge 1 commit into
devfrom
fix/ui-surface-lifetime
Open

Furroxide wants to merge 1 commit into
devfrom
fix/ui-surface-lifetime

Conversation

@Furroxide

Copy link
Copy Markdown
Owner

Builds on #248, which is still open: its two commits show here until it merges, and this branch then rebases onto
dev, leaving one commit. The window fixes below build on #248's deferred clamp.

Outcome

SDK UI surfaces now live as long as the mod, or the scope, that made them, and a gameplay HUD needs no scene code. A
mod makes its HUD once, in OnLoad; TopiaForge shows it in every world and hides it at the main menu and while a scene
loads. This fixes five defects that #227's review proved in the game on Robotopia 0.0.2545, for every mod with UI,
offline and online alike.

  • Surfaces outlive scenes. OwnerUiService made non-persistent canvases in the active scene. A HUD made in
    OnLoad never reached the screen, because mods load before the game's first scene, and a window made in a world
    vanished at the next world load while IUiSurface.IsVisible still said true. Every SDK surface (HUD, window,
    fullscreen tool) and every SDK modal now draws on a persistent canvas (feat(multiplayer): add the in-game sessions tab and connecting overlay #225's persistence), owned by the mod's or the
    scope's lifetime. No SDK surface ends with a scene: a mod cannot see a scene change coming, and every flow it builds,
    a world session included, already has a lifetime of its own. A surface a gamemode session's scope makes ends with the
    session. Kit layers made directly keep the kit's default and stay scene-owned unless created persistent: true.
  • A destroyed surface says so. If the game or another mod destroys a surface's canvas anyway, the surface is never
    reported visible again, a visible one raises Dismissed, its id is free for a new surface, and the mod's log says,
    in plain words, that it was destroyed outside TopiaForge and to create it again. A modal on such a canvas completes as
    cancelled, and windows, tools and dialogs on it give the cursor and the Escape stack back.
  • Gameplay HUDs follow gameplay (automatic first). A HUD shows only in gameplay by default
    (UiHudVisibility.Gameplay): hidden at the main menu (by GameScenes.IsNonGameplayScene, the template's own
    classification) and while any scene loads, shown again in the next world. IsVisible reports what the player sees,
    so the multiplayer template counts a press only while hud.IsVisible. TopiaForge's hiding never raises
    Dismissed. The opt-out is one call: request.WithHudVisibility(UiHudVisibility.Always). Windows and fullscreen
    tools show exactly while the mod shows them.
  • Sorting slots go back by what was allocated. TopiaForgeLayers.Release read the order from a canvas a scene had
    destroyed, so the slot leaked: the HUD went from 30001 to 30002 across one world change, in a band of 800. Every kit
    canvas now holds its slot through TopiaForgeLayerLedger, keyed by the canvas object, and a component on each canvas
    gives the slot back whenever anything destroys it. Window focus moves the record with the order it permutes, so the
    registry and the ledger always agree. In the game the counter's HUD kept order 30000 across six world changes, and
    destroying it from outside released its slot.
  • The HUD text column fills its panel. It kept a new GameObject's 100 x 100 units, centred, so "Shared count: N"
    wrapped (53 pixels of body at 1600x900). It now fills the panel inside the padding: a 260-unit HUD wraps body text at
    224 units, at every UI scale, and its title stays on one line.
  • Placement, decided and documented. In every level the game draws its microphone label at the top left, a
    development build's version at the top, a level's objective at the top right (above every mod's UI), a crosshair at
    the centre and dialogue subtitles across the bottom middle (measured in five levels at 2560x1440). A gameplay HUD now
    docks at the bottom left by default, and a HUD shown everywhere at the middle of the left edge, which also keeps
    clear of the main menu's TopiaForge buttons. WithHudAnchor picks any corner or edge middle (UiHudAnchor), never
    the centre. HUDs docked at one place stack a small gap apart, first created nearest the edge, so HUDs from different
    mods never overlap. Toasts moved from the top right, under the level objective, to the bottom right, stacking
    upwards.
  • Docs name the real property. The TF1008 docs (docs/Diagnostics.md) and PendingOperation's XML example used
    Context.Time.Frame.UnscaledTime, which does not exist; they now use (float)Context.Time.Frame.ElapsedTime, and the
    example compiles. No other use remains.
  • Two window defects fix(ui-kit): keep windows whole when shown on an unmeasured canvas #248's author found. TopiaForgeWindow.Show during a close fade was swallowed, so a surface's
    Hide() then Show() ended hidden although the surface said it showed; the fade now stops and the window stays, as
    the fullscreen tool already did. Windows were clamped only when shown, so a new resolution or UI scale could leave a
    title bar off screen; a component on each window's canvas now arms fix(ui-kit): keep windows whole when shown on an unmeasured canvas #248's deferred clamp when the canvas changes
    size, so the window is re-placed on the next frame.
  • Consumers, simplified. The multiplayer template makes one HUD in OnLoad and lost its SubscribeSceneLoaded
    bookkeeping, its ShowHudForWorld method and its HUD field (the file is 26 lines shorter); its tests and README
    follow. The ui template's window now
    survives scene loads; its test proves the same window toggles after a world and the main menu. The SDK acceptance
    mod's OnLoad HUD and modal now reach the main menu; its HUD shows everywhere, since its last step is taken at the
    main menu. No first-party mod re-created surfaces per scene.
  • The testing kit tells the truth too. FakeUiSurface.IsVisible follows the fake scenes as the game does, and
    IsShown says whether the mod shows a surface.

Compatibility and security

  • Public API/schema/save/package impact: additive, baselines refreshed.
    • TopiaForge.Mods.Abstractions gains UiHudAnchor, UiHudVisibility, and UiSurfaceRequest.HudAnchor,
      HudVisibility, WithHudAnchor and WithHudVisibility (21 baseline lines added, none changed). The copy methods
      keep the constructor binary-compatible; on a window or fullscreen request they throw, saying to create a HUD.
    • TopiaForge.Mods.Testing gains FakeUiSurface.IsShown (1 line).
    • The kit (TopiaForge.Mods.UnityUi, the loader's internal renderer, no SDK baseline) gains
      TopiaForgeLayerLedger<TCanvas>, TopiaForgeHudColumn, TopiaForgeHudDock, TopiaForgeLayerBands.Holds,
      TopiaForgeTokens.ScrollGutter and UiHost.LayerLost; TopiaForgeCorner moved to the Unity-free Core/ folder
      (same namespace and assembly).
  • Behaviour every mod sees:
    • surfaces and modals survive scene loads; a modal stays open until answered or closed, or its mod or scope ends;
    • a gameplay HUD is hidden, and reports IsVisible false, at the main menu and during loads; a mod whose HUD must
      show there opts out with UiHudVisibility.Always;
    • HUDs dock at the bottom left by default instead of the top left, and stack; toasts rest at the bottom right;
    • a mod's own tests on FakeModContext start at the main menu, so a gameplay HUD is hidden until the test loads a
      world (context.Scenes.Load(...)); a test that asserted IsVisible on a HUD without loading a world needs that
      line or Always;
    • a window shown again during its close fade stays shown, and a window follows its canvas's size.
  • Filesystem/network/archive/process or credential impact: none. No address, name or path is logged; the one new
    log line names the surface's id.
  • Migration or rollback: revert. Nothing is persisted in a new format.

Deviations from the plan

  • HUD stacking was not asked for. A single default anchor would put every mod's HUD in one place, so HUDs sharing
    an anchor stack; without it the default would trade one overlap for another.
  • The two window fixes come from fix(ui-kit): keep windows whole when shown on an unmeasured canvas #248's author, at the coordinator's request.
  • The ui template still installs disabled. Its manifest category is DevTool, which TopiaForge installs
    disabled, while its README says to install and press U. Left for a follow-up: the category is a product decision.

Verification

On a Windows 11 x64 machine, on dev at bc3153f (#245) plus #248's head (559c1ea):

  • dotnet build TopiaForge.slnx -c Release -warnaserror --no-incremental: 0 warnings, 0 errors.
  • dotnet run --project tests\TopiaForge.ModManager.Tests\TopiaForge.ModManager.Tests.csproj -c Release: "All
    TopiaForge tests passed.", 197 lines reporting a pass, among them the new UiKitLayerLedgerTests,
    UiKitHudLayoutTests, UiSurfaceLifetimeTests, UiSurfaceLifetimeConventionTests and
    TestingKitTests.UiPresence, fix(ui-kit): keep windows whole when shown on an unmeasured canvas #248's UiKitWindowMathTests, and the 363 gamemode conformance fixtures.
  • ModRuntime: 4 of 4 suites passed. Mods.Analyzers, Mods.Multiplayer.Generators and Mods.Multiplayer (the soak, the
    fuzzing and the network world over the real provider): passed.
  • dotnet format TopiaForge.slnx --verify-no-changes: only the 7 known Windows findings in RobotProgramDirector.cs;
    none in a file this pull request touches.
  • dotnet restore TopiaForge.slnx, then bash tools/verify-csharp-release-surface.sh: "Validated 11 SDK packages and
    all 7 C# release harnesses."
  • check_topiaforge_residue.py, check_readme_counts.py and node website/scripts/check-markdown-links.mjs (147
    files): pass. No Dart file changed.
  • The templates, as CI runs them: scaffolded with the worktree's CLI, restored, mod sync multiplayer,
    dotnet build -warnaserror (0 warnings) and dotnet test: the multiplayer template's 6 tests and the ui
    template's test pass; the multiplayer template's menu and load assertions can only pass against the updated testing
    kit. The CLI's multiplayer template packs only after its contract lock is synchronized and lists built-in templates pass.
  • What the tests cover: a HUD made before the first scene, hidden at the menu and during loads, shown in every
    world, through 1,000 loads, never dismissed by TopiaForge; a HUD shown everywhere; the mod's own show and hide; a
    destroyed surface never visible again and dismissed once; a player-closed window; disposal; the gate's scene
    classification, its load detection and isolated listeners; sorting slots released by the record after the canvas is
    destroyed, a late destroy notice unable to free another canvas's slot, window focus moving the record, shared
    exhausted orders, and 1,000 scene changes with HUDs, windows and dialogs leaking no slot; the column math (a 260 x 96
    HUD wraps body text at 224 units at any UI scale) and degenerate panels; the dock at every anchor, stacking, closing
    up, centred edges, mixed UI scales and a settled frame allocating nothing; default anchors clear of the game's HUD;
    the fakes following fake scenes; the request copy methods and their refusals; and, read from the sources, the Unity
    wiring: persistent surfaces and modals, slots released by the record, lost canvases reported to their owners, the
    gate sampled before mods update, toasts at the bottom right, and both window fixes.
  • Mutation checks: 23 changes, all caught by their own tests, each made, built, run and restored, with the
    restored tree built, passing and clean afterwards:
    • the three the plan asked for: the order read from the canvas again (in Release, and in the window registry), a
      surface left non-persistent (HUD, window, fullscreen tool and modal each), and a destroyed surface reported visible
      (in the presence, and in the surface);
    • a lost canvas never reported to the owner, a destroyed canvas keeping its slot, the ledger keeping a released record
      or not following focus, HUDs shown during loads, the gate dismissing a HUD, the column left at 100 x 100 or wrapped
      at the old width, HUDs stacked on top of each other, gameplay HUDs back at the top left, toasts back at the top
      right, Show swallowed during a close fade, no re-placement on a canvas size change, fake HUDs ignoring the
      scenes, and the gate never sampled.
  • In the game, Robotopia 0.0.2545 on a scratch overlay of the game, windowed through the shared windowed-display
    patch at 2560x1440 and switched to 1920x1080 in the same run, with this branch's runtime and first-party mods, the
    scaffolded multiplayer and ui templates and the SDK acceptance mod installed. A temporary probe, kept out of the
    branch, loaded scenes through the game's own loader (SceneUtil.LoadScene), called small drivers in scratch copies
    of the two templates (presses go through the K key's own gate), clicked kit buttons through their own handlers,
    logged the scene counts every frame, and captured the game's frames. Every run was killed, never quit.
    • The game's own HUD, in five levels: the microphone label at the top left, the version at the top, the
      objective at the top right, the crosshair, and dialogue subtitles across about the middle half of the bottom edge;
      the bottom left and bottom right were free in every level.
    • Loads: while the game's loader runs, the engine lists the incoming scene as loading (two scenes, one loaded)
      until the old one unloads in a single frame.
    • The multiplayer template's HUD: hidden before the first scene and at the main menu (two presses there did not
      count) and while City Streets loaded; shown in City Streets at the bottom left, where three presses counted;
      hidden at the main menu with the count kept; shown again in City Streets with "Shared count: 3". Its canvas kept
      order 30000 through six world changes. Destroying its canvas from outside left it reported hidden, a press no
      longer counted, every sorting slot came back, and the mod's log said in plain words that it was destroyed outside
      TopiaForge and to create it again.
    • The text column: at 1920x1080 and UI scale 1.0 the column is the panel's 260 x 96 pixels, the title 236 wide
      and the body 224, on one line; 299 and 448 pixels at 2560x1440 and UI scales 1.0 and 1.5, and 336 at 1920x1080
      and 1.5, one line each, high contrast included.
    • The ui template's window, opened at the main menu, stayed open through City Streets and back to the main
      menu. Hide() then Show() in one frame left it visible (it faded back in). Moved near the bottom right and then
      put on a UI-scale-1.5 canvas, it was re-placed fully on screen, its right edge at the window's edge.
    • The SDK acceptance mod's OnLoad surfaces: its modal showed at the main menu over a scrim, and its HUD, shown
      everywhere, sat at the middle of the left edge at the main menu and in City Streets, clear of TopiaForge's buttons.
    • Placement: a second gameplay HUD stacked above the counter's at the bottom left, a small gap apart; toasts of
      one and three lines stacked up from the bottom right, above the window, leaving the objective uncovered; nothing
      covered the microphone label, the version or the objective at either size, UI scale 1.0 or 1.5, or high contrast.
    • Real input was not used: the checks went through the game's loader, the kit's own handlers and the K key's
      gate. A pass with real input is pending for the owner.

Checklist

  • I read AGENTS.md and preserved CLEAN/Bloc/Unity/TopiaForge UI boundaries.
  • I added focused regression coverage for meaningful defects.
  • I ran the relevant build, analyze, test, format, package, and line-count gates.
  • I added provenance, license text, and modification notes for new third-party material. (None was added.)
  • I did not include secrets, proprietary game assemblies, generated build directories, or unrelated changes.

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Unity PR artifacts are ready.

Artifact Link
VPM package listing Download artifact

Workflow run: Unity Source Validation #673
Commit: 1c48d68

Artifacts are temporary and expire according to the workflow retention policy.

SDK UI surfaces now live as long as the mod, or the scope, that made
them, and a gameplay HUD needs no scene code.

- Every SDK HUD, window, fullscreen tool and modal draws on a persistent
  canvas. A HUD made in OnLoad, before the game's first scene, reaches
  the screen, and a window survives the next world load. A scope's
  surfaces end with its scope.
- A surface whose canvas the game or another mod destroys is never
  reported visible again: a visible one raises Dismissed, its id is
  freed, and the mod's log says to create it again. Windows, tools and
  modals on such a canvas give the cursor and Escape stack back; a modal
  ends as cancelled.
- Gameplay HUDs show only while a loaded gameplay scene is active and no
  scene loads, sampled each frame before mods update.
  UiHudVisibility.Always opts out. IsVisible reports what the player sees.
- Sorting slots go back by what was allocated (TopiaForgeLayerLedger),
  never by reading a destroyed canvas, so a HUD no longer moves up the
  band with every world change.
- A HUD's text column fills its panel, so short lines never wrap.
- HUDs dock bottom-left by default (left edge when shown everywhere),
  clear of the game's microphone label, version, objective and
  subtitles, and HUDs sharing an anchor stack. UiHudAnchor picks another
  place. Toasts stack up from the bottom right, off the level objective.
- A window shown again during its close fade stays shown, and a window
  is re-placed when its canvas changes size.
- The TF1008 docs and PendingOperation's example name ElapsedTime.
- The multiplayer template makes one HUD in OnLoad with no scene
  bookkeeping; the ui template, the SDK acceptance mod and the testing
  kit's fakes follow the new lifetime.

Signed-off-by: Furroxide <221987073+Furroxide@users.noreply.github.com>
@Furroxide
Furroxide force-pushed the fix/ui-surface-lifetime branch from de770e7 to 1c48d68 Compare October 19, 2026 04:12

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants