Framework architecture
Status: implemented as src/Stellar.{Abstractions,Wire,Application,Infrastructure,Host,PluginContracts,Analyzers}/ (framework 2.11.0).
This document records the architectural decisions and the discoveries that drove them.
Critical discovery from the IL2CPP dump: HybridCLR
Section titled “Critical discovery from the IL2CPP dump: HybridCLR”HybridCLR.Runtime.dll is loaded into the game. HybridCLR is a hot-update framework that lets Unity IL2CPP games load additional managed C# assemblies at runtime — bypassing the normal “everything must be AOT-compiled” IL2CPP restriction. This is unusual and very important.
Evidence:
Assembly-CSharp.dllis only 50.5 KB in the IL2CPP dump — almost no game code is compiled in. Everything substantive lives in HybridCLR-loaded hot-update DLLs that ship via the patcher / CDN.- The
Panda.AOT.*assembly prefix indicates the AOT-compiled “anchor” stubs HybridCLR needs; the actualPanda.*game logic (Panda.Hud,Panda.Script,Panda.Table,Panda.ZRpcGen, etc.) is the hot-update side. - Hot-update payloads ship inside
StreamingAssets/container/m*.pkgpackages, decrypted/loaded byHybridCLR.Runtimeat boot.
Why this matters for our framework
Section titled “Why this matters for our framework”Compared to a typical IL2CPP game where everything is native AOT and you fight Il2CppInterop for every method call:
| Typical IL2CPP game | Star Resonance (HybridCLR) | |
|---|---|---|
| Game logic representation | Native AOT, accessed via Il2CppInterop proxies | Interpreted/JIT’d managed CIL, like a Mono game |
| Hooking game methods | Native function hook via MinHook + Il2CppInterop trampolines | HarmonyX directly, like Dalamud patches FFXIV |
| Access to game types | Generated proxy wrappers | Direct Type.GetType / reflection works |
| Patches survive game updates | Brittle (signatures shift) | More robust — HarmonyX matches by name/signature |
This makes the target much more Dalamud-shaped than initially assumed. The actual technique stops being “wrap IL2CPP” and starts being “wait for HybridCLR to finish, then HarmonyX-patch as if it were a Mono game.” Empirically confirmed: the framework’s HarmonyX postfixes on Panda.Core.Game lifecycle methods apply cleanly to the hot-update managed code.
Stack decision
Section titled “Stack decision”| Layer | Choice | Reason |
|---|---|---|
| Loader / process injection | BepInEx 6 IL2CPP (be.755) | Most mature for Unity 2022 LTS IL2CPP. Loads before HybridCLR initializes, so we can sequence our framework startup to wait for hot-update load. |
| Native interop | Il2CppInterop | Only needed for the Unity engine surface (UnityEngine.*) and AOT stubs — most game logic doesn’t need it. |
| Method patching | HarmonyX | Works directly on HybridCLR-loaded managed assemblies. Same library Dalamud uses. |
| Overlay | Native Unity uGUI — a canvas hierarchy driven by WindowService |
Renders through the game’s own Canvas/UI system with zero native hooks. (The original v0.2 shipping path used Unity IMGUI via an injected OverlayBehaviour.OnGUI; that was deleted in Phase E because the per-frame OnGUI crossing cost ~13 fps.) |
| Plugin DI | Custom service locator (IPluginServices) |
Game uses VContainer internally; we expose a small, explicit surface rather than wrapping the container directly. |
| Event bus to plugins | Composed IGameEventBridge strategy (GameEventsService): MessagePipe first (MessagePipeContainerBridge, using the game’s VContainer once it is resolved off Game.GameRoot, plus root-scope / GlobalMessagePipe routes), then a HarmonyX fallback (HarmonyEventBridge) |
Plugins use the same IGameEvents.Subscribe(typeName, handler) API regardless of which bridge serves a subscription; subscriptions made before a bridge is ready are buffered and replayed. |
The overlay is built from native uGUI: plugins register windows via IWindowHost (backed by WindowService), composing element trees rather than issuing immediate-mode draw calls. On-screen HUD overlays are the same windows registered borderless with Surface = SurfaceStyle.HudOverlay — there is no separate HUD service.
Component layout
Section titled “Component layout”How a server packet becomes a plugin event: parsed and queued on the network thread, fanned out on the main thread.
Diagram sources and the build are in diagrams/.
Clean Architecture across five runtime assemblies, plus a shared plugin-contracts assembly and a Roslyn analyzer. Dependency rule is enforced by project references + InternalsVisibleTo:
src/├── Stellar.Abstractions/ plugin-facing contracts (public). BCL only, plus a compile-time│ reference to the 0Harmony stub (IHarmonyHost returns HarmonyLib.Harmony).├── Stellar.Wire/ internal wire protocol (frame parse / stub routing / method IDs).│ depends on: Abstractions (BCL + Abstractions only)├── Stellar.Application/ services + outbound interfaces (internal).│ depends on: Abstractions├── Stellar.Infrastructure/ adapters: BepInEx / HarmonyX / Unity IL2CPP / MessagePipe.│ depends on: Abstractions, Application, Wire + external runtimes├── Stellar.Host/ composition root. The only place that says `new ConcreteThing(...)`.│ depends on: Abstractions, Application, Infrastructure├── Stellar.PluginContracts/ shared inter-plugin contracts brokered via IPluginExchange.│ depends on: Abstractions. The framework never references it│ (it brokers purely by Type) but ships it in the bundle.└── Stellar.Analyzers/ Roslyn analyzer (STELLAR0001–0006) injected into every src/ project.tests/ Stellar.Application.Tests, Stellar.Analyzers.TestsSample and shipping plugins live outside this repo (the public plugin registry and per-plugin repos).
Plugin authors reference the SDK packages only — Stellar.Abstractions, optionally Stellar.PluginContracts, and the Stellar.Plugin.InteropRefs compile-time stubs (all published to NuGet.org by the release workflow). They physically cannot touch internals — the compiler stops them.
Bootstrap sequence (high level)
Section titled “Bootstrap sequence (high level)”The startup sequence at a glance:
- BepInEx loads
Stellar.Host(and the .NET runtime resolves the other framework DLLs next to it: Infrastructure, Application, Abstractions, Wire). BootstrapPlugin.Load()constructs all services and adapters (including the uGUIWindowServicestack), wires them via constructor injection.AppDomainHotUpdateWatcherwaits for all 8 hot-update Panda assemblies to load.- On all-loaded: register the framework’s own uGUI windows (settings hub, launcher, perf overlay), install the wire probes and 5 HarmonyX postfixes on
Panda.Core.Gamelifecycle methods (Init,OnLogin,OnLogout,OnEnterScene,OnLeaveScene— deliberately notUpdate), load user plugins from<game>/stellar/plugins/**/*.dll, then start theStellarTickerclock.
Framework tick — single variable-speed clock (v1.7.0+)
Section titled “Framework tick — single variable-speed clock (v1.7.0+)”Stellar drives all its per-frame work from one injected StellarTicker MonoBehaviour using
InvokeRepeating, not a per-frame Update — most rendered frames have zero managed entry (the
“managed-crossing tax” the IMGUI overlay was deleted to avoid). As of v1.7.0 that clock is
variable-speed: its rate = max(global rate, every plugin's effective rate), clamped [10, 240] and
realized at ≤ the render frame rate.
A TickScheduler (Application) owns the rate math and gates each consumer behind a per-consumer
accumulator (RateGate), so consumers tick at their own rate off the shared clock. Each beat runs three bands:
- Every beat — the exchange (market) Lua-bridge drain only. Cheap when idle; riding the master clock means a ramped plugin’s market round-trips complete proportionally faster (the lever behind the market-snipe feature).
- Per-plugin Updates — each plugin’s
IFramework.Updatefires at its own configured/dynamic rate. - Global-gated — the expensive draw/refresh/input work, pinned to the global rate via an accumulator, so raising the clock for one plugin never multiplies HUD draw cost. The equip and loadout Lua-bridge drains also run here (they have no latency need).
Plugins set a persistent per-plugin rate (Settings → Performance) or temporarily ramp via
IFramework.RequestUpdateRate (permission-gated, leak-guarded). Idle (nothing ramped) the clock rests at the
global rate and behaviour is identical to pre-1.7.0. See plugin-development.md.
Game phases, tick gating, and window visibility (SDK 2.0)
Section titled “Game phases, tick gating, and window visibility (SDK 2.0)”Earlier the framework tick was suppressed entirely until the player was in-world: a single blanket gate
(if (sceneTransitioning) return;) short-circuited RunFrameworkTick, so nothing the framework drives —
window draw, input poll, hotkeys — ran before world-connect. That made a login-screen tool (account switcher,
server picker) impossible: its window couldn’t render or be interacted with at the title screen. The blanket
gate existed only because the corrupting work (live game-state probing that scrambles the world-connect
handshake) was never isolated from the safe work (drawing UI, polling input).
SDK 2.0 splits those two concerns and drives everything off two independent signals, both exposed on
IClientState:
GamePhase Phase(Startup→TitleScreen→CharSelect→World, inStellar.Abstractions.Domain) — a first-class client-lifecycle signal. The framework gates nothing on it; it exists purely for plugins to read. It is distinct from session state (IsLoggedIn/Login/Logout) and coexists with it —Phaseanswers “which client screen are we on,” session state answers “are we logged in.”Phasestays steadyWorldacross in-world zone loads.PhaseChanged(event Action<PhaseChange>,PhaseChangeareadonly record struct(From, To)) fires on each transition.bool IsWorldActive— the only protective gate. True in a stable world scene, false mid-transition; stricter thanPhase == Worldbecause it also dips false during in-world zone loads (the connect / scene-switch handshake), which is exactly when live game-state reads corrupt the connection.
The tick is now a dumb dispatcher. The blanket gate is gone; RunFrameworkTick calls all its work every
phase. Correctness moved to a per-unit self-gate: each thing that touches live game state early-returns on
if (!_clientState.IsWorldActive) return; — framework probes/services (PlayerState, Inventory, world-attr,
equip/loadout), notice-tips (which run game Lua), and the Host’s own plumbing (_framework.Tick, game-data
load, ProbeGameRootOnce). The draw service (Window) and UI/input do not gate — they are inherently
safe and run in every phase, which is what lets a window appear at the title screen. Gating is opt-in per what
a unit does: a plugin that only draws UI, does HTTP, or reads framework-cached data needs no gate; only a
unit doing raw game reads self-gates.
Two signals, two jobs: use Phase for visibility, IsWorldActive for game-state access — never
gate game-state on Phase/IsLoggedIn (both are true mid-transition).
Window visibility is plugin-owned. A single compiler-required predicate Func<bool> ShouldRender
(the IRenderGated contract, on WindowSpec — HUD overlays are windows too) is the sole source of visibility truth; the framework
only enacts hide = !ShouldRender(), evaluated each apply (~10 Hz) — a pull, never a stored flag. The plugin
reads whatever it wants inside the predicate (Phase, UiState, its own state). The old
AutoHideBehindGameMenus / HideUntilInWorld bools are removed; MasterHudKill stays as an explicit dev
override outside policy. required means omitting ShouldRender fails the build — no login-screen-spam footgun
by default.
GameUIState ([Flags], in Stellar.Abstractions.Domain) is an informational in-world UI signal the
framework detects and exposes but never gates on. Flat co-occurring bits (GameHud, FullScreenMenu,
MainMenu, LineSelector, Dialogue, Cutscene, Loading, Matchmaking, Popup) plus preset masks
(GameHudHidden, AnyMenu, Blocking); None at the title screen. A gameplay HUD’s ShouldRender
reads it to hide itself when a menu covers the HUD.
Safety-net (framework src/ only): a framework game-state unit that forgets its IsWorldActive guard
corrupts the world-connect and disconnects everyone, whereas a plugin that forgets its own gate harms only
itself. So a Roslyn analyzer (Stellar.Analyzers, STELLAR0006) fails the build if a method marked
[WorldGated] lacks the guard. It runs on framework projects only and never applies to plugin projects —
no plugin is ever forced to gate.
Full design, decision table, and the in-game validation record: game-phases-design.md.
What’s deliberately out of scope
Section titled “What’s deliberately out of scope”- Packet modification. Read-only inspection only. Even without anti-cheat, sending forged packets to a live server is the line between QoL and exploitation. See
README.mdfor the full QoL-only stance. - Cracking the
m*.pkgcontainer format. Not needed — HybridCLR will load DLLs into memory and HarmonyX patches them there. - Cross-version compatibility shims. Re-run recon after each patch instead; the framework targets one game version at a time.
Open questions (post-v0.2)
Section titled “Open questions (post-v0.2)”- MessagePipe container path — RESOLVED. The VContainer
IObjectResolveris reached throughGame.GameRoot’s container once it is populated: the Host probes it on the in-world framework tick until it is found (ProbeGameRootOnce+ResolverProbe), then hands it to the MessagePipe bridge and the inventory probe. - Friendly scene names — RESOLVED for plugins.
OnEnterScenestill delivers numeric scene IDs ("1","7"), and that is whatIClientState.CurrentSceneNamecarries. The name comes from the scene table:IGameData.World.GetScene(id)returns aSceneInfowithNameandMapId. - Overlay technology — RESOLVED. The original v0.2 question asked whether Unity IMGUI would prove limiting (no images, ugly styling). It did, and it also cost ~13 fps via the per-frame
OnGUIcrossing. Resolved in Phase E: the IMGUI overlay was deleted and the framework migrated to native Unity uGUI. A Dear ImGui DX12 swapchain hook is no longer being considered.