Imported from rosudrag/se-phyzix (
AGENTS.md). Install upstream withnpx skills add rosudrag/se-phyzix. Copyright stays with the author.
se-phyzix
Client-side SE1 plugin (Pulsar / Interim, net10.0-windows). Spreads bulk entity creation across
frames so a streaming storm does not stall the frame for seconds.
Revived 2026-08-27 after retirement. The old build and design were both broken; check "History" before trusting any older note or issue comment.
What it does
One seam. MyEntities.UpdateAfterSimulation (Sandbox.Game, decompile 1291-1296) drains the
entity-creation thread with an unbounded loop:
if (MyMultiplayer.Static != null && m_creationThread.AnyResult)
while (m_creationThread.ConsumeResult(MyMultiplayer.Static.ReplicationLayer.GetSimulationUpdateTime())) { }
Each iteration runs MyEntities.Add → OnAddedToScene (render object alloc, physics registration,
hierarchy update). A whole sector finishing in one tick lands in one frame. Phyzix bounds the loop:
Patch_EntityCreation.UpdateAfterSimulationprefix →EntityAddBudget.BeginFrame()opens a per-frame window (MaxEntityAddsPerFramecount cap +FrameBudgetMstime cap).Patch_EntityCreation.ConsumeResultprefix → sets__result = falseonce spent, ending the game'swhileloop.
Undrained results stay in m_resultQueue, consumed next frame — deferred, never dropped. The
budget always admits at least one entity per frame, so a backlog cannot stall.
The game's MyMultiplayer.Static != null guard makes this multiplayer-only; the plugin is a no-op
offline. The budget also stands down while MySession.Static.Ready is false — spreading work behind
a loading screen only lengthens the load.
Second seam: network-streamed voxels
Voxels skip the creation thread: MyVoxelReplicable.OnLoad (:80-182) decompresses storage, builds
the entity and calls MyEntities.Add synchronously, so the budget can't see them. Patch_VoxelLoad
captures the load; VoxelLoadQueue replays it from the UpdateAfterSimulation postfix on the same
budget.
Deferral is legal because loadingDoneHandler is an async continuation, not a synchronous
requirement: MyExternalReplicable.OnLoad (:244-250) only wraps it, and MyCubeGridReplicable
already completes it a later frame via MyEntities.InitAsync (:94-97). The replicable stays in
MyReplicationClient.m_pendingReplicables meanwhile, exactly as a grid does.
Load-bearing details, each easy to get wrong:
- Copy the stream, don't reference it.
MyReplicationClient.ProcessReplicationCreatecallspacket.Return()right afterOnLoad(:383), recycling the buffer. - Copy from the buffer base, then seek.
ResetRead(BitStream, copy)starts atBytePosition(the ceiling of the bit position), dropping the rest of the current byte when unaligned.VoxelLoadQueue.CapturecopiesDataPointerfor the fullBitLength, thenSetBitPositionReads back — bit-exact. MyVoxelReplicableis internal, resolved by name viaTargetMethods(). It yields nothing when unresolved; returning null fromTargetMethodmakes Harmony throw and kills the patch pass.- Every queued load MUST resolve its handler exactly once. This is the invariant that broke
first. Strand one and its replicable stays in
m_pendingReplicables, soMySession.PendingStreamingRelicablesCountnever decrements and the streaming indicator spins for the rest of the session.Replayreports a failed load when the original did not run, andClearresolves rather than dropping. MyExternalReplicable.IsValidis NOT a "still wanted?" test. It readsInstance(MyEntityReplicableBase.cs:38-48), whichHookInternalonly assigns fromOnLoadDoneafter a successful load — so it is false for every load that has not happened yet. Using it as a pre-check discarded 100% of deferred loads. There is no pre-check: a dead replicable is the game's problem, andSetReplicableReady(:242) re-checks its pending table and no-ops on a stale or duplicate resolve.- Do not defer unless the pump is proven live.
TryDeferrequiresEntityAddBudget.FramesObserved > 0, so if theUpdateAfterSimulationpostfix ever fails to apply, loads run inline instead of being held forever.
The queue is capped (MaxDeferredVoxelLoads, default 64) because each held load owns a packet-buffer
copy; past the cap loads run inline, degrading to vanilla instead of eating memory.
What it deliberately does NOT do
Three earlier mechanisms, removed as inert or harmful. Do not reintroduce.
- Throttling
MyReplicationClient.RequestReplicable. No bulk caller exists. Every in-game caller is a single user action (MyCameraBlock,MyRemoteControl,MyGuiScreenTerminal,MyGuiScreenMedicals,MyVisualScriptLogicProvider). Streaming is server-driven; deferring these only adds latency to camera switching, terminal opening and respawn. - Queueing
MyEntities.CreateFromObjectBuilderbuilders and returningnull. The result is dereferenced byCreateFromObjectBuilderAndAdd(MyEntities.cs:1941), and the old queue could only be fulfilled by re-issuingRequestReplicable, impossible offline. Asteroids went missing. - Deferring voxel rigid-body creation.
DelayRigidBodyCreation(MyVoxelBase.cs:418) has one consumer,MyVoxelMap.InitVoxelMap(:366), and lazy physics onMyVoxelPhysicsBody.OnAddedToScene(:155) creates the bodies anyway. The old code also reflected a non-existentMyVoxelBase.CreateVoxelPhysics.
Build
dotnet build ClientPlugin\ClientPlugin.csproj -c Release
SDK-style, net10.0-windows, Lib.Harmony 2.4.2 compile-only (ExcludeAssets="runtime" — Interim
provides Harmony; a bundled 0Harmony.dll breaks binding). Game DLLs referenced from $(Bin64) in
Directory.Build.props; no Bin64 junction needed. Post-build copies Phyzix.dll + .pdb to
$(Bin64)\Pulsar\Legacy\Local\ (locked destination → warning). /p:SkipDeploy=true suppresses the
copy. No .deps.json, runtimeconfig.json, or App.config.
Publishing to PluginHub
Pulsar compiles registry plugins from source, not from the csproj — the csproj only exists for
local development and the Local\ drop. PluginHub.xml → SourceDirectories → every *.cs under
ClientPlugin. Verified against the shipped Pulsar.Compiler.dll (2.3.1). Libraries\{Interim, Legacy,Modern}\Pulsar.Compiler.dll are byte-identical (md5 6b833a9c…) — one assembly copied three
times, so language version and allowUnsafe cannot differ by launcher. What does differ is set by
each launcher: the reference list (Interim.dll / Legacy.exe → References.baseEnvironment), the
preprocessor symbols, and the runtime directory.
| Value | |
|---|---|
| Language version | C# 14 (LanguageVersion 1400) — both launchers |
allowUnsafe |
true — but the tree does not rely on it. BitStream.DataPointer is declared unsafe and returns IntPtr, not a pointer type, so no unsafe context is needed at the use site; the csproj carried a redundant AllowUnsafeBlocks and a wrong comment until it was verified with -t:Rebuild |
| References | SpaceEngineers*, VRage*, Sandbox*, ProtoBuf* from Bin64, minus VRage.Native.dll, plus 0Harmony, Newtonsoft.Json, Mono.Cecil, NLog, Microsoft.CSharp, WPF/WinForms |
| Runtime refs | RuntimeEnvironment.GetRuntimeDirectory() — net10 under Interim, net48 under Legacy |
| Defines | NETCOREAPP;TRACE (Interim) or NETFRAMEWORK;TRACE (Legacy) |
| Source fetch | github.com/<repo>/archive/<Commit>.zip |
| Build cache key | Commit + RuntimeInformation.FrameworkDescription |
Three consequences that are easy to get wrong:
- The tree must compile against net48 as well as net10. One registry entry serves both
launchers and each caches its own build, so a net-only API (
Dictionary.TryAdd,ArgumentNullException.ThrowIfNull,Enum.GetValues<T>()) breaks every Legacy user while building fine locally. Probe it with a throwawaynet48project that globs..\ClientPlugin\**\*.csand pullsMicrosoft.NETFramework.ReferenceAssemblies; keepobj/binoutside the globbed tree or the SDK's generatedAssemblyAttributes.cscollides (CS0579). - Never rewrite history once a hash is submitted. The registry pins a commit and Pulsar downloads that exact archive. A force-push orphans it and the download eventually 404s.
SourceDirectoriestakes as many<Directory>entries as you need and their union is what compiles. It is onestring[]([XmlArray]+[XmlArrayItem("Directory")],GitHubPlugin.cs:27-29; the property shape confirmed by reflection against the shippedLibraries\Interim\Pulsar.Shared.dll2.3.1),AllowedZipPathiterates every entry and admits any archive path that starts with one of them (GitHubPlugin.cs:334-341), andLocalFolderPluginmaps each entry to a source root (LocalFolderPlugin.cs:306-308). So a multi-project repo lists<Directory>ClientPlugin</Directory><Directory>Shared</Directory>and both trees compile — the list is a prefix allow-list, not a single root. Keep them as siblings inside one<SourceDirectories>element: two sibling wrapper elements are not merged, becauseXmlSerializerreassigns the array per occurrence and the last wrapper wins (measured on net48 and net10, 2026-08-27).
Properties\AssemblyInfo.cs is hand-maintained (GenerateAssemblyInfo=false) because it is part of
the compiled tree. The registry validator (test.py) requires Commit to match ^[0-9a-f]+$.
After a game update
..\tools\refresh-decompile.ps1 -Force
dotnet run --project ..\se-protomolecule\tools\harmony-audit -- "<Bin64>" "<repo>\ClientPlugin"
Both patch targets bind with nameof + an explicit argument-type array, so the compiler catches a
rename or new overload. Every patch body is try/catch-wrapped and logs — an escaping exception kills
the loader.
History
- Pinned build stopped compiling at 1.209.022:
MyGuiScreenOptionsControlsrenamed toMyGuiScreenOptionsMouseKeyboard(issues #1, #2; PR #1 by WesternSpace, merged). - Issue #3 (dawidmachon) reported four defects on 1.210.014. All resolved, three by deleting the mechanism that carried them.