Compare commits

...
10 Commits
Author SHA1 Message Date
EmilandClaude Sonnet 5 a77086b518 Docs: CI is green on both platforms — M4's literal "done when" is met
Build / build (windows-latest, win-x64) (push) Canceled after 0s
Build / build (ubuntu-latest, linux-x64) (push) Canceled after 0s
.github/workflows/build.yml just ran clean end to end on both
ubuntu-latest and windows-latest (build, full test suite, native shim
compilation, headless physics verification) after fixing the two real
cross-platform bugs its first run caught. Updating README/kernel-contract
to say so instead of "pending."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 21:06:00 +03:00
EmilandClaude Sonnet 5 b238fb5b19 Fix CI: real failures found by the first actual workflow run, not guessed
Both jobs failed, at genuinely informative points:

- linux-x64: DllNotFoundException loading lingua_physics — the .so
  committed for scripts/run-sample.sh's local-dev convenience was built on
  this dev machine's own (newer) glibc, and didn't load on ubuntu-latest's
  runner. liblingua_audio.so, built the same way, loaded fine there — this
  wasn't a generic "the file isn't where expected" problem, it was
  specific to what that one binary happened to require. Fixed by having CI
  rebuild and overwrite the native libs fresh for both platforms, every
  run, rather than trusting the committed one for anything but casual
  local use — only a binary built on the actual target platform is
  trustworthy on it.

- win-x64: "WGL: The driver does not appear to support OpenGL" — from
  inside the --headless verification run. --headless only controls
  whether Engine.Host's own loop pumps a window; it does nothing to stop
  a *loaded* engine.windowing/engine.render from creating a real window
  and GL context regardless, which the full PhysicsDemo project always
  does. Fine on a real desktop, fatal on windows-latest's GPU-less
  runner. Fixed with a separate, minimal verification stage — engine.
  physics only, project.ci-headless.json/scene.ci-headless.json, no
  windowing/render/audio/game plugin at all — alongside the original full
  stage, which still produces the real uploaded artifact. engine.physics
  needs no display, so this is what CI can actually check without one;
  audio's own correctness is separately covered by Engine.Audio.Tests
  (forced onto miniaudio's null backend already) on both platforms.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 21:02:42 +03:00
EmilandClaude Sonnet 5 cd8f221ddd Fix real bugs from independent review (docs/review-handoff.md)
A second AI session read the whole codebase in parallel (read-only, no
code changes) and left a handoff doc. Addressed the correctness findings:

- PhysicsWorld.Sync's early return compared _bodies.Count to live.Count,
  not their contents — same size, different membership (destroy one
  tracked GameObject, gain one untracked-because-no-collider one; or any
  Restore where the scene has both a Rigidbody+collider object and a
  Rigidbody-without-one) skipped cleanup entirely, leaking the native
  Box3D body forever. Fixed by checking the actual stale set. Two new
  regression tests reproduce the review's own two scenarios via a new
  Lingua_GetBodyCount native export, asserting on the native table's own
  count rather than PhysicsWorld's C#-side bookkeeping.
- TryCreateBody stored handle -1 (native shim refused: invalid world, or
  its 8192-slot body table full) as if it were real — every later
  GetBodyTransform on it silently teleported the GameObject to the
  origin with a degenerate rotation, no error anywhere. Now checked and
  warned once, same as the missing-collider case.
- PluginHost.Load didn't roll back anything when Configure threw partway
  through: Schedule.Add/Events.Subscribe registrations it already made
  stayed forever, and its ALC was never unloaded — neither loaded (no
  _loaded entry) nor cleanly unloadable. Fixed with try/catch: best-effort
  Shutdown (the only thing that knows which services this plugin
  provided), RemoveAllFrom on both Schedule and EventBus, best-effort ALC
  unload, rethrow. New fixture plugin (sandbox.failing-configure, mirrors
  sandbox.echo's own real-load pattern) registers a system against a
  shared Ping component then throws, so FailedConfigureRollbackTests can
  assert the dangling system actually stops firing — an earlier version
  tried to prove this via AssemblyLoadContext.All instead, which passed
  even against the deliberately-reverted buggy code (the ALC turned out
  to get collected either way once its only references went out of
  scope); watching the dangling system is what actually distinguishes
  rolled-back from not, confirmed by deliberately reverting the fix and
  watching this specific test fail before restoring it.
- Engine.Host's "r <id>" left a plugin unloaded on a failed reload with
  no honest indication of that, and retrying threw "not loaded" instead
  of ever reaching Load again. Added PluginHost.IsLoaded so the handler
  only calls Unload when there's something to unload, and the failure
  message now says the plugin is unloaded, not just "failed."
- AssetService.ReloadWithRetry's `when (attempt < 4)` guard meant the
  5th and final IOException fell out of the loop and propagated from a
  discarded fire-and-forget Task — no log, no event, nothing. Now logged.
- EditorState.Selected kept pointing at a GameObject Restore had already
  destroyed after ExitPlay, so Inspector/gizmo would silently keep
  editing something no longer in the world. EditorPlugin.DrawUi now
  compares IsPlaying against its own previous frame (not just reacting to
  the Stop button) so this is caught whether Play was exited via the
  button or the "stop" stdin command — the same stdin-vs-real-control gap
  already hit once earlier this session — and re-resolves the selection
  by name.
- README.md and kernel-contract.md's Event Bus/Time rows had fallen a
  milestone behind (still said "no asset system yet" and "no fixed-step
  accumulator yet" after both shipped).

Full suite: 98 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 20:57:08 +03:00
EmilandClaude Sonnet 5 faabfc2cb4 M4: Linux + Windows CI build pipeline (.github/workflows/build.yml)
The literal thing nothing on this dev machine can verify: whether the
build actually runs on Windows at all. Solved by having real CI do it —
ubuntu-latest and windows-latest both build the native Box3D/miniaudio
shims from source via CMake, build and test the full .NET solution, stage
a shippable configuration (engine.windowing/assets/render/input/physics/
audio + physics-demo-game — engine.editor deliberately excluded, that's
M4's actual "done when"), and then run it headless against samples/
PhysicsDemo for 200 frames, asserting DemoBox settled at y≈1.0 in the
resulting dump. Real physics, real scene load, real plugin loading,
proven on both platforms, not just built.

Required restructuring the native Content items into per-OS ItemGroups
(native/linux-x64/ vs native/win-x64/, selected via
$([MSBuild]::IsOSPlatform(...))): linux-x64's .so is committed (built and
verified here); win-x64's .dll is never committed — nothing here can
build or run one to verify — and only ever exists as something the
Windows job produces fresh, in-place, right before `dotnet build`.

Caught one real bug dry-running this exact staging locally before
trusting it to a workflow run: PluginHost.Load calls Assembly.
LoadFromAssemblyPath, which throws on a relative path — the verification
step's `--plugins ../../plugins` failed immediately with "is not an
absolute path." Every manual verification earlier this session happened
to always pass an absolute --plugins path, which is exactly why this
never surfaced before. Fixed by resolving to an absolute path before
invoking Engine.Host.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 17:52:28 +03:00
EmilandClaude Sonnet 5 2d0168104d M4: PhysicsDemo — the "one small game, end to end"
samples/PhysicsDemo ties physics, audio, input, and rendering together
through nothing but the kernel's own vocabulary — no plugin here
references another plugin's implementation, only Contracts. Press Space
to drop a box; engine.physics simulates it falling onto a static ground;
CubeRenderer (new — engine.render's QuadRenderer drew flat cards, no good
for a physics demo where a BoxCollider needs to actually look like a box)
draws it; physics-demo-game watches each spawned box's own Y velocity via
IPhysicsService and plays a bounce sound via IAudioService the moment a
real fall settles. A looping ambient track plays throughout via
AudioSource's own PlayOnAwake. project.json deliberately excludes
engine.editor — this is the shippable configuration M4's "done when"
actually asks for, not the dev one.

"Landed" isn't a Box3D contact event — the native shim never exposed one
(nothing but scalars crosses that boundary, see lingua_physics.c). Watching
velocity every frame is the honest, right-sized alternative for a demo
this size, not a shortcut around missing infrastructure.

Verified two ways. First, real Space-key input isn't simulable here (no
xdotool/ydotool under this Wayland session) — so PhysicsDemoGame.Tests
drives the actual PhysicsDemoGamePlugin.Configure/Tick through the real
Schedule with a controllable fake IEngineInput (and fake IPhysicsService/
IAudioService, since engine.physics/engine.audio's own correctness is
already covered elsewhere): spawn-on-press with edge detection, the
Rigidbody/BoxCollider/CubeRenderer combo the spawned box actually gets,
and — the case most likely to be subtly wrong — a box that never actually
falls doesn't false-positive as "landed," only one that fell past
FallingThreshold and then settled does, exactly once. All 5 passed
immediately. Second, a real windowed run: a box placed in scene.json above
the ground visibly falls and settles onto it on screen after a real
few-second wait — screenshotting by --screenshot-after-frames alone turned
out not to prove this (VSync is off, so frames race by far faster than
real physics time passes; enough elapsed frames isn't enough elapsed
seconds), an interactive run with a real sleep before the screenshot
command is what actually shows it.

Full suite: 92 tests. Remaining for M4: the Linux + Windows build pipeline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 17:47:15 +03:00
EmilandClaude Sonnet 5 88e0afa46b Fix: exiting Play mode from the editor's own Stop button crashed
Real bug, caught by hand (not by any automated test): clicking Stop in
the Lingua Editor panel threw InvalidOperationException — "A system
structurally changed 'QuadRenderer' without declaring Writes<QuadRenderer>()".

Root cause: GameWorld.Restore rebuilds the world via GameObject.
AddComponent, which SystemAccessScope checks. That's fine when Restore is
called from unconstrained code — every existing WorldSnapshotTests test
calls it directly, outside any system, which is exactly why none of them
caught this. But PlayModeController.ExitPlay is called from inside
EditorPlugin's own Stage.Render system (the button click handler runs as
part of DrawUi), which correctly declares no Reads/Writes at all — it has
no compile-time knowledge of QuadRenderer or any other game's component
types. So the ambient SystemAccessScope was still active when Restore
tried to rebuild them.

SystemAccessScope's own doc comment already said "editor code... [is]
unconstrained by design" — true only when the call happened to originate
outside a system's scope, not actually true in general. Fixed with
SystemAccessScope.Suspend(), which GameWorld.Restore now wraps its entire
rebuild in: Restore is a bulk, whole-world reset regardless of who calls
it, the same category of operation Destroy already is (which never went
through the checked RemoveComponent path to begin with).

New regression test reproduces the exact shape: a system with no declared
access calling Restore, run through the real Schedule — not calling
SystemAccessScope directly, and not calling Restore outside a system
either, which is why this slipped through the first time. Passed
immediately after the fix; would have failed loudly before it.

Full suite: 87 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 17:24:14 +03:00
EmilandClaude Sonnet 5 c6d9ff1a26 Add scripts/stage-plugins.sh and run-sample.sh for running the engine locally
Contracts and Implementation build into two separate bin/ folders, so
there's nowhere valid to point --plugins at without a staging step first
— every manual verification this session has done that staging by hand.
stage-plugins.sh does it for every plugin in one pass; run-sample.sh
stages then runs samples/WindowDemo windowed, forwarding extra args to
`engine run` (e.g. --screenshot).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 17:16:24 +03:00
EmilandClaude Sonnet 5 dcadc57061 M4: engine.audio — miniaudio, same narrow-shim shape as engine.physics
native/audio-native/lingua_audio.c wraps miniaudio (vendored at pinned
release 0.11.25 — dual public domain/MIT-0, confirmed from the license
statement in the file itself) the same way lingua_physics.c wraps Box3D:
ma_engine_config/ma_sound_config are large structs with optional
callbacks, so nothing but plain int/float/bool/UTF-8-path scalars crosses
the P/Invoke boundary, and handles are this shim's own array-index
handles into a fixed ma_sound table, not miniaudio's own pointer types.
Same DllImport-not-LibraryImport choice too, for the same reason: zero
AllowUnsafeBlocks needed anywhere in this plugin.

One real addition beyond mirroring the physics shim: Lingua_Audio_Init
takes a useNullBackend flag. miniaudio's null backend runs the exact same
load/decode/mix/loop pipeline against a device that discards its output —
real coverage of this shim's logic without depending on real audio
hardware being present (most CI runners have none) or making an
automated test run produce actual sound, which nobody expects. Real
gameplay (AudioPlugin) always requests the real backend; only
Engine.Audio.Tests asks for the null one.

engine.audio adds an AudioSource component (ClipPath/Volume/Loop/
PlayOnAwake) and AudioWorld, which — same shape as PhysicsWorld, same
reason: no destruction event exists to hook — diffs Query<AudioSource>()
against its own tracked set every Stage.Update, loading a clip the first
time it sees one, applying Volume/Loop changes only when they actually
changed, and unloading sounds whose GameObject is gone. IAudioService
exposes Play/Stop/IsPlaying for gameplay code to trigger a sound instead
of it only ever firing on PlayOnAwake.

5 new tests, all against the null backend, all passed after fixing one
real bug they caught: AudioWorld originally re-attempted loading (and
re-warned about) a missing clip on every single Sync call instead of
once — MissingClip_WarnsAndDoesNotThrow failed on the first run with 2
warnings instead of 1, fixed by tracking failed-load GameObjects the same
way PhysicsWorld already tracks missing-collider ones.

Full suite: 86 tests. Not yet verified: actual audible playback through a
real device — the null-backend tests prove this plugin's own logic, but
nobody has listened to real output yet. Worth doing deliberately, not as
a surprise mid-session — flagging rather than just doing it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 17:13:21 +03:00
EmilandClaude Sonnet 5 792b626396 M4: engine.physics — Box3D, over a narrow, verified P/Invoke shim
native/physics-native/lingua_physics.c wraps Box3D at a specific pinned
commit (47d7f7c — Box3D has no 1.0 release yet, and its only tag, v0.1.0,
already diverges from this API, confirmed by diffing headers rather than
assuming). The wrapper is deliberately narrow: Box3D's own b3WorldDef/
b3BodyDef/b3ShapeDef are large structs with function pointers, and
b3BoxHull's own doc comment says it "has data hanging off the end and
cannot be directly copied" — none of that crosses the P/Invoke boundary.
Every exported Lingua_* function takes and returns only plain int32/float/
bool scalars, and handles are this shim's own array-index handles, not
Box3D's id structs. C# binds them with classic DllImport rather than the
newer LibraryImport specifically because LibraryImport's generated
marshalling needs AllowUnsafeBlocks even for an all-scalar signature like
every one of these — DllImport needs none, keeping this plugin inside the
kernel's "no unsafe in the v1 hot path" rule with no exception required.

engine.physics adds Rigidbody/BoxCollider/SphereCollider components and
PhysicsWorld, which diffs Query<Rigidbody>() against its own tracked set
every Stage.FixedUpdate (there's no destruction event to hook) to create
and destroy native bodies, steps Box3D once per invocation — Engine.Host's
accumulator decides how many times that runs per frame, not this plugin —
and writes each body's resulting transform back to GameObject.Transform.
IPhysicsService exposes ApplyLinearImpulse/Get/SetLinearVelocity for
gameplay code.

Verified twice: a standalone C smoke test against the native shim alone
(a box dropped from y=5 onto a static ground settles at y≈1.0, exactly
where the two half-heights sum to), and the full pipeline through Engine.
Host — a headless run with a real scene, --dump showing the same box
settling at y=0.9999 after physics, scene load, and Stage.FixedUpdate all
went through the real kernel. 5 new automated tests in Engine.Physics.
Tests cover the same settling behavior for both shapes, a missing-collider
warning that fires once and doesn't throw, cleanup after a GameObject is
destroyed mid-simulation, and that an applied impulse actually changes
velocity — all passed on the first run.

Physics-enabled GameObjects must be root-level for now: PhysicsWorld
writes Box3D's world-space transform straight into LocalPosition/
LocalRotation, correct only when local and world space are the same
thing. A parented rigidbody needs the same parent-WorldMatrix-inverse
handling GizmoMath.WorldToLocalPosition already does for the gizmo — real,
not-yet-done work, not silently wrong.

Full suite: 81 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 17:05:39 +03:00
EmilandClaude Sonnet 5 c2bcb9b9fe M4: Stage.FixedUpdate + Time's fixed-step accumulator
Both were explicitly deferred to M4 by ITime and Stage's own doc comments
back when they were written: a FixedUpdate stage with no real accumulator
behind it would be actively misleading, and building the accumulator with
no physics system to test it against would be untested speculative
machinery. engine.physics (next) is the real consumer.

Time.ConsumeFixedSteps accumulates DeltaTime and hands back how many
FixedDeltaTime-sized (1/50s) steps it can pay for, capped at 5 per frame
so a real stall becomes visible lag instead of a catch-up burst of
physics steps. Engine.Host calls it once per frame in both the headless
and windowed loops, running Stage.FixedUpdate that many times before
Stage.Update — gated by IPlayModeController.IsPlaying the same way Update
already is, and only accumulating time while actually playing, so
entering Play doesn't open with a burst of steps for however long Edit
mode had been sitting idle.

3 new tests on the accumulator itself (below-one-step, whole-steps-plus-
remainder, the 5-step cap under a simulated stall). Full suite: 76 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 16:54:08 +03:00
74 changed files with 98875 additions and 33 deletions
+153
View File
@@ -0,0 +1,153 @@
name: Build
# M4's "done when": the build runs on both platforms with no editor
# plugins in the shipped binary. Neither half of that is verifiable from
# a single Linux dev machine — this workflow is what actually proves it,
# real builds on real ubuntu-latest and windows-latest runners, not just
# a cross-platform-looking csproj and hope.
on:
push:
branches: [master]
pull_request:
workflow_dispatch:
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
rid: linux-x64
- os: windows-latest
rid: win-x64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Build native physics shim (Box3D, pinned commit — see native/physics-native/CMakeLists.txt)
shell: bash
run: |
cmake -S native/physics-native -B native/physics-native/build -DCMAKE_BUILD_TYPE=Release
cmake --build native/physics-native/build --config Release
- name: Build native audio shim (miniaudio)
shell: bash
run: |
cmake -S native/audio-native -B native/audio-native/build -DCMAKE_BUILD_TYPE=Release
cmake --build native/audio-native/build --config Release
# win-x64's .dll can only ever be produced here — nothing else in
# this project can build or run one to check it. linux-x64's .so
# IS also committed (for scripts/run-sample.sh's convenience on a
# dev machine), but CI rebuilds and overwrites it fresh too, every
# run: a real failure found by this exact workflow, not a
# hypothetical one — the committed .so, built locally against a
# newer glibc than ubuntu-latest ships, loaded fine here and threw
# DllNotFoundException on the runner. Only a binary built on the
# actual target platform is trustworthy there.
- name: Place native libraries
shell: bash
run: |
mkdir -p "plugins/engine.physics/Engine.Physics/native/${{ matrix.rid }}"
mkdir -p "plugins/engine.audio/Engine.Audio/native/${{ matrix.rid }}"
find native/physics-native/build -iname "liblingua_physics.so" -o -iname "lingua_physics.dll" \
| xargs -I{} cp {} "plugins/engine.physics/Engine.Physics/native/${{ matrix.rid }}/"
find native/audio-native/build -iname "liblingua_audio.so" -o -iname "lingua_audio.dll" \
| xargs -I{} cp {} "plugins/engine.audio/Engine.Audio/native/${{ matrix.rid }}/"
ls "plugins/engine.physics/Engine.Physics/native/${{ matrix.rid }}"
ls "plugins/engine.audio/Engine.Audio/native/${{ matrix.rid }}"
- name: Build
run: dotnet build LinguaEngine.sln -c Release
# Nothing in the suite opens a real GL window or a real audio device
# (Engine.Audio.Tests forces miniaudio's null backend specifically so
# this works) — the whole thing runs headless on both runners.
- name: Test
run: dotnet test LinguaEngine.sln -c Release --no-build
- name: Stage the shippable build (engine.editor deliberately excluded)
shell: bash
run: |
set -euo pipefail
DIST="dist/${{ matrix.rid }}"
STAGE="$DIST/plugins"
mkdir -p "$STAGE"
for id in engine.windowing engine.assets engine.render engine.input engine.physics engine.audio; do
impl="$(awk -F. '{for(i=1;i<=NF;i++){$i=toupper(substr($i,1,1)) substr($i,2)}}1' OFS=. <<<"$id")"
src="plugins/$id/$impl/bin/Release/net9.0"
mkdir -p "$STAGE/$id"
cp -r "$src/." "$STAGE/$id/"
cp "plugins/$id/plugin.json" "$STAGE/$id/"
done
mkdir -p "$DIST/engine"
cp -r src/Engine.Host/bin/Release/net9.0/. "$DIST/engine/"
GAME_DST="$DIST/samples/PhysicsDemo/GamePlugins/physics-demo-game"
mkdir -p "$GAME_DST"
cp -r samples/PhysicsDemo/GamePlugins/physics-demo-game/bin/Release/net9.0/. "$GAME_DST/"
cp samples/PhysicsDemo/GamePlugins/physics-demo-game/plugin.json "$GAME_DST/"
cp -r samples/PhysicsDemo/assets "$DIST/samples/PhysicsDemo/assets"
cp samples/PhysicsDemo/project.json samples/PhysicsDemo/scene.json "$DIST/samples/PhysicsDemo/"
# A deliberately minimal, engine.windowing/engine.render-free stage,
# separate from the full one above: found the hard way that --headless
# only controls whether Engine.Host's own loop pumps a window — it
# does nothing to stop a *loaded* engine.windowing/engine.render from
# creating a real window and GL context regardless, which the full
# PhysicsDemo project always does. That's fine on a real desktop; on
# windows-latest's GPU-less runner it's "WGL: The driver does not
# appear to support OpenGL," failing before physics ever got to run.
# engine.physics needs no display at all, so this checks only that.
- name: Stage a minimal headless-safe build for verification
shell: bash
run: |
set -euo pipefail
VERIFY="verify/${{ matrix.rid }}"
mkdir -p "$VERIFY/plugins/engine.physics"
cp -r plugins/engine.physics/Engine.Physics/bin/Release/net9.0/. "$VERIFY/plugins/engine.physics/"
cp plugins/engine.physics/plugin.json "$VERIFY/plugins/engine.physics/"
mkdir -p "$VERIFY/engine"
cp -r src/Engine.Host/bin/Release/net9.0/. "$VERIFY/engine/"
mkdir -p "$VERIFY/samples/PhysicsDemo"
cp samples/PhysicsDemo/project.ci-headless.json "$VERIFY/samples/PhysicsDemo/project.json"
cp samples/PhysicsDemo/scene.ci-headless.json "$VERIFY/samples/PhysicsDemo/scene.json"
- name: Verify the shippable build actually runs (headless, real physics + real scene load, real P/Invoke)
shell: bash
run: |
set -euo pipefail
# Absolute paths only: Assembly.LoadFromAssemblyPath (which
# PluginHost.Load uses) throws on a relative one — found by
# hand running this exact staging locally before trusting it to
# a workflow run at all.
VERIFY="$(pwd)/verify/${{ matrix.rid }}"
cd "$VERIFY/samples/PhysicsDemo"
dotnet "$VERIFY/engine/Engine.Host.dll" run --headless \
--plugins "$VERIFY/plugins" --project project.json --scene scene.json \
--frames 200 --dump dump.json
cat dump.json
python3 -c "
import json
data = json.load(open('dump.json'))
box = next(o for o in data if o['name'] == 'DemoBox')
y = box['transform']['position'][1]
assert 0.9 < y < 1.1, f'DemoBox should have settled near y=1.0, got y={y}'
print(f'DemoBox settled at y={y:.4f} — physics ran for real on ${{ matrix.rid }}')
"
- uses: actions/upload-artifact@v4
with:
name: lingua-engine-${{ matrix.rid }}
path: dist/${{ matrix.rid }}
+23
View File
@@ -18,7 +18,30 @@ Thumbs.db
out/
artifacts/
## scripts/stage-plugins.sh's output — regenerated from plugins/, not source.
.stage/
## ImGui's own persisted window layout (position/size/open-state) — a
## per-machine editing session preference, not project source, same
## category as an IDE's own workspace state.
imgui.ini
## CMake build output and FetchContent's cloned dependencies (Box3D) for
## native/*-native/ — regenerated by cmake -S . -B build, not source; only
## the built liblingua_physics.so (checked in under plugins/engine.physics/
## Engine.Physics/native/) is what the engine actually ships.
native/*/build/
native/*/.fetchcontent-cache/
## scripts/run-physics-demo.sh copies the game-specific plugin's built
## output directly into its own folder (that's where its own pluginPaths
## entry in project.json points) — regenerated, not source. plugin.json,
## the .csproj, and the .cs files there stay tracked.
samples/*/GamePlugins/*/*.dll
samples/*/GamePlugins/*/*.pdb
## Windows builds of the native physics/audio shims — nothing here can
## build or verify one (no Windows machine), so unlike linux-x64's .so,
## win-x64's .dll is never committed. .github/workflows/build.yml's
## Windows job builds it fresh into this exact path before `dotnet build`.
**/native/win-x64/
+155
View File
@@ -57,6 +57,38 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Editor", "plugins\en
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Editor.Tests", "tests\Engine.Editor.Tests\Engine.Editor.Tests.csproj", "{7F1CF468-6717-4152-BA95-2DA1E011B6CB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.physics", "engine.physics", "{5BFA9F87-CB7C-360F-A763-812D3AAB4167}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Physics.Contracts", "plugins\engine.physics\Engine.Physics.Contracts\Engine.Physics.Contracts.csproj", "{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Physics", "plugins\engine.physics\Engine.Physics\Engine.Physics.csproj", "{A9205D28-762B-49C0-8CFF-8D59BA330786}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Physics.Tests", "tests\Engine.Physics.Tests\Engine.Physics.Tests.csproj", "{70245995-311C-4CBE-A433-F4035303BEDB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.audio", "engine.audio", "{2D4F0C3C-7532-0E50-D9FD-7209344A42BE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Audio.Contracts", "plugins\engine.audio\Engine.Audio.Contracts\Engine.Audio.Contracts.csproj", "{2C2E545D-6937-476D-AC62-F96CEA6403F9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Audio", "plugins\engine.audio\Engine.Audio\Engine.Audio.csproj", "{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Audio.Tests", "tests\Engine.Audio.Tests\Engine.Audio.Tests.csproj", "{E610BD6A-A732-4863-8D19-555B75134E13}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PhysicsDemo", "PhysicsDemo", "{51874E45-60A1-E43B-395E-A3BE4050752F}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GamePlugins", "GamePlugins", "{9760813E-A577-3C56-DE20-0218A108BABD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "physics-demo-game", "physics-demo-game", "{83FF5113-4091-0AE3-A9AD-50B7BB917258}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhysicsDemoGame", "samples\PhysicsDemo\GamePlugins\physics-demo-game\PhysicsDemoGame.csproj", "{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhysicsDemoGame.Tests", "tests\PhysicsDemoGame.Tests\PhysicsDemoGame.Tests.csproj", "{0201686F-0E2A-400F-8166-CBD11B65D724}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sandbox.failing-configure", "sandbox.failing-configure", "{AB8A31F6-ADF4-A607-5774-F0AC7E353102}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sandbox.FailingConfigure", "plugins\sandbox.failing-configure\Sandbox.FailingConfigure\Sandbox.FailingConfigure.csproj", "{428A0279-9B16-473D-92BC-858D4ABB15E2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -283,6 +315,114 @@ Global
{7F1CF468-6717-4152-BA95-2DA1E011B6CB}.Release|x64.Build.0 = Release|Any CPU
{7F1CF468-6717-4152-BA95-2DA1E011B6CB}.Release|x86.ActiveCfg = Release|Any CPU
{7F1CF468-6717-4152-BA95-2DA1E011B6CB}.Release|x86.Build.0 = Release|Any CPU
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|x64.ActiveCfg = Debug|Any CPU
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|x64.Build.0 = Debug|Any CPU
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|x86.ActiveCfg = Debug|Any CPU
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|x86.Build.0 = Debug|Any CPU
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|Any CPU.Build.0 = Release|Any CPU
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|x64.ActiveCfg = Release|Any CPU
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|x64.Build.0 = Release|Any CPU
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|x86.ActiveCfg = Release|Any CPU
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|x86.Build.0 = Release|Any CPU
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|x64.ActiveCfg = Debug|Any CPU
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|x64.Build.0 = Debug|Any CPU
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|x86.ActiveCfg = Debug|Any CPU
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|x86.Build.0 = Debug|Any CPU
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|Any CPU.Build.0 = Release|Any CPU
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|x64.ActiveCfg = Release|Any CPU
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|x64.Build.0 = Release|Any CPU
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|x86.ActiveCfg = Release|Any CPU
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|x86.Build.0 = Release|Any CPU
{70245995-311C-4CBE-A433-F4035303BEDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{70245995-311C-4CBE-A433-F4035303BEDB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{70245995-311C-4CBE-A433-F4035303BEDB}.Debug|x64.ActiveCfg = Debug|Any CPU
{70245995-311C-4CBE-A433-F4035303BEDB}.Debug|x64.Build.0 = Debug|Any CPU
{70245995-311C-4CBE-A433-F4035303BEDB}.Debug|x86.ActiveCfg = Debug|Any CPU
{70245995-311C-4CBE-A433-F4035303BEDB}.Debug|x86.Build.0 = Debug|Any CPU
{70245995-311C-4CBE-A433-F4035303BEDB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{70245995-311C-4CBE-A433-F4035303BEDB}.Release|Any CPU.Build.0 = Release|Any CPU
{70245995-311C-4CBE-A433-F4035303BEDB}.Release|x64.ActiveCfg = Release|Any CPU
{70245995-311C-4CBE-A433-F4035303BEDB}.Release|x64.Build.0 = Release|Any CPU
{70245995-311C-4CBE-A433-F4035303BEDB}.Release|x86.ActiveCfg = Release|Any CPU
{70245995-311C-4CBE-A433-F4035303BEDB}.Release|x86.Build.0 = Release|Any CPU
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|x64.ActiveCfg = Debug|Any CPU
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|x64.Build.0 = Debug|Any CPU
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|x86.ActiveCfg = Debug|Any CPU
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|x86.Build.0 = Debug|Any CPU
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|Any CPU.Build.0 = Release|Any CPU
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|x64.ActiveCfg = Release|Any CPU
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|x64.Build.0 = Release|Any CPU
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|x86.ActiveCfg = Release|Any CPU
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|x86.Build.0 = Release|Any CPU
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|x64.ActiveCfg = Debug|Any CPU
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|x64.Build.0 = Debug|Any CPU
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|x86.ActiveCfg = Debug|Any CPU
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|x86.Build.0 = Debug|Any CPU
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|Any CPU.Build.0 = Release|Any CPU
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|x64.ActiveCfg = Release|Any CPU
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|x64.Build.0 = Release|Any CPU
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|x86.ActiveCfg = Release|Any CPU
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|x86.Build.0 = Release|Any CPU
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|x64.ActiveCfg = Debug|Any CPU
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|x64.Build.0 = Debug|Any CPU
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|x86.ActiveCfg = Debug|Any CPU
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|x86.Build.0 = Debug|Any CPU
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|Any CPU.Build.0 = Release|Any CPU
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|x64.ActiveCfg = Release|Any CPU
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|x64.Build.0 = Release|Any CPU
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|x86.ActiveCfg = Release|Any CPU
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|x86.Build.0 = Release|Any CPU
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x64.ActiveCfg = Debug|Any CPU
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x64.Build.0 = Debug|Any CPU
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x86.ActiveCfg = Debug|Any CPU
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x86.Build.0 = Debug|Any CPU
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|Any CPU.Build.0 = Release|Any CPU
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x64.ActiveCfg = Release|Any CPU
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x64.Build.0 = Release|Any CPU
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x86.ActiveCfg = Release|Any CPU
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x86.Build.0 = Release|Any CPU
{0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x64.ActiveCfg = Debug|Any CPU
{0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x64.Build.0 = Debug|Any CPU
{0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x86.ActiveCfg = Debug|Any CPU
{0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x86.Build.0 = Debug|Any CPU
{0201686F-0E2A-400F-8166-CBD11B65D724}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0201686F-0E2A-400F-8166-CBD11B65D724}.Release|Any CPU.Build.0 = Release|Any CPU
{0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x64.ActiveCfg = Release|Any CPU
{0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x64.Build.0 = Release|Any CPU
{0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x86.ActiveCfg = Release|Any CPU
{0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x86.Build.0 = Release|Any CPU
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|x64.ActiveCfg = Debug|Any CPU
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|x64.Build.0 = Debug|Any CPU
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|x86.ActiveCfg = Debug|Any CPU
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|x86.Build.0 = Debug|Any CPU
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|Any CPU.Build.0 = Release|Any CPU
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|x64.ActiveCfg = Release|Any CPU
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|x64.Build.0 = Release|Any CPU
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|x86.ActiveCfg = Release|Any CPU
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -312,5 +452,20 @@ Global
{6A2943F1-1984-470D-BD74-C6E16BB77241} = {8067C66D-9D49-192B-E4C1-1C024401ACE3}
{B60A3933-923C-4339-98D1-E4C87A76E80B} = {8067C66D-9D49-192B-E4C1-1C024401ACE3}
{7F1CF468-6717-4152-BA95-2DA1E011B6CB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{5BFA9F87-CB7C-360F-A763-812D3AAB4167} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D} = {5BFA9F87-CB7C-360F-A763-812D3AAB4167}
{A9205D28-762B-49C0-8CFF-8D59BA330786} = {5BFA9F87-CB7C-360F-A763-812D3AAB4167}
{70245995-311C-4CBE-A433-F4035303BEDB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{2D4F0C3C-7532-0E50-D9FD-7209344A42BE} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
{2C2E545D-6937-476D-AC62-F96CEA6403F9} = {2D4F0C3C-7532-0E50-D9FD-7209344A42BE}
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB} = {2D4F0C3C-7532-0E50-D9FD-7209344A42BE}
{E610BD6A-A732-4863-8D19-555B75134E13} = {2D4F0C3C-7532-0E50-D9FD-7209344A42BE}
{51874E45-60A1-E43B-395E-A3BE4050752F} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
{9760813E-A577-3C56-DE20-0218A108BABD} = {51874E45-60A1-E43B-395E-A3BE4050752F}
{83FF5113-4091-0AE3-A9AD-50B7BB917258} = {9760813E-A577-3C56-DE20-0218A108BABD}
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF} = {83FF5113-4091-0AE3-A9AD-50B7BB917258}
{0201686F-0E2A-400F-8166-CBD11B65D724} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{AB8A31F6-ADF4-A607-5774-F0AC7E353102} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
{428A0279-9B16-473D-92BC-858D4ABB15E2} = {AB8A31F6-ADF4-A607-5774-F0AC7E353102}
EndGlobalSection
EndGlobal
+29 -2
View File
@@ -116,8 +116,35 @@ and is unit-tested on its own — including the case a screenshot can't
easily catch, dragging an object parented under a non-uniformly-scaled
parent.
No physics yet — see the build order (M0–M4) in
[`docs/kernel-contract.md`](docs/kernel-contract.md) for what's next.
**M4 in progress — the "done when" is met.** `engine.physics` wraps Box3D
and `engine.audio` wraps miniaudio, both through a deliberately narrow C
shim — neither library's own config structs (large, with function
pointers and, for Box3D, at least one type that "cannot be directly
copied") cross the P/Invoke boundary, only plain scalars and handles do.
`samples/PhysicsDemo` ties physics, audio, input, and rendering together
in one running project with `engine.editor` deliberately left out of its
`project.json` — the shippable configuration, not the dev one.
`.github/workflows/build.yml` builds both native shims from source, runs
the full test suite, and headless-verifies real physics end to end, on
real `ubuntu-latest` and `windows-latest` runners — the only way to check
the Windows half at all from a single Linux dev machine. **Both platforms
are green.** The first real run caught two things no amount of local
testing could have: the linux-x64 `.so` committed for local dev
convenience, built against this dev machine's own newer glibc, didn't
load on `ubuntu-latest`'s (CI now rebuilds it fresh every run instead of
trusting a locally-built binary); and `--headless` doesn't stop a loaded
`engine.windowing`/`engine.render` from creating a real GL context
regardless, which fails outright on a GPU-less runner (CI's verification
step now stages a separate, `engine.physics`-only build with no window
plugin at all — `engine.render`'s own correctness is proven by real
screenshots on a real desktop instead, not something a headless runner
could check anyway).
Still open for M4: the "one small game" target is the physics demo so
far, not yet a scored 20-minute one.
See the build order (M0–M4) in
[`docs/kernel-contract.md`](docs/kernel-contract.md) for the rest.
Design and implementation are argued over in the same place: the doc is
still the thing to disagree with before code changes to match.
+3 -3
View File
@@ -53,8 +53,8 @@ nothing.
| 02 | **Scheduler** | Frame stages, topological system ordering, parallel execution of systems with disjoint declared access, and debug-mode enforcement of that access — see §7. Structural changes (adding/removing a `GameObject` or `Component`) are queued and applied at the stage boundary, so a running system never sees a collection mutate under it. |
| 03 | **Plugin Host** | Manifest parsing, dependency resolution, ALC loading, unloading, reload. |
| 04 | **Service Registry** | Publishing and discovering interfaces between plugins. Control path, not the hot path. |
| 05 | **Event Bus** | `Publish`/`Subscribe`, ownership-tracked and leak-safe the same way as the Scheduler's systems — see §7. `PluginHost` publishes `PluginLoaded`/`PluginUnloaded`; nothing publishes `GameObject` created/destroyed or asset-reloaded facts yet — the first because `GameWorld` doesn't touch the bus at all (a publish on every structural change would tax the hot path for listeners that usually don't exist), the second because there's no asset system yet. |
| 06 | **Time & Log** | Frame clock (`DeltaTime`, `ElapsedTime`, `FrameCount`) and logging, both on `IPluginContext`. No fixed-step accumulator yet — deferred to M4, alongside the physics system it would actually drive. |
| 05 | **Event Bus** | `Publish`/`Subscribe`, ownership-tracked and leak-safe the same way as the Scheduler's systems — see §7. `PluginHost` publishes `PluginLoaded`/`PluginUnloaded`; `engine.assets` publishes `TextureReloaded` when a watched file changes on disk (M2). Still nothing publishes `GameObject` created/destroyed facts — `GameWorld` doesn't touch the bus at all, deliberately: a publish on every structural change would tax the hot path for listeners that usually don't exist. |
| 06 | **Time & Log** | Frame clock (`DeltaTime`, `ElapsedTime`, `FrameCount`, `FixedDeltaTime`) and logging, both on `IPluginContext`. The fixed-step accumulator (`Time.ConsumeFixedSteps`) shipped with M4, driving `Stage.FixedUpdate` — `engine.physics` is its first real consumer. |
`GameObject.Transform` is the one field embedded directly rather than
modeled as a `Component` subclass — it's a plain struct holding local
@@ -436,7 +436,7 @@ cost of changing course is still zero.
| **M1** | **Window, input, a triangle.** Three separate plugins over Silk.NET. First real-load test of the data channel. | The triangle's color changes by editing system code, with no app restart. |
| **M2** | **Assets and scenes.** Hot-reloading asset plugin, scene format, `World` serialization. | Swapping a texture on disk changes the picture with nothing stopped; a scene loads and saves. |
| **M3** | **Editor as plugins — done.** Shell (`engine.editor`, ImGui over the live scene, see §5 and the new `Stage.Present`), reflection-based inspector and hierarchy, Play/Stop on snapshots, a real 3D translate gizmo driven by the actual camera. | Entering Play takes under 100 ms — the original complaint about Unity is closed. Proven twice: `WorldSnapshotTests` (kernel, 300 `GameObject`s) and a real editor run against `samples/WindowDemo` (13ms, logged by `PlayModeController`). |
| **M4** | **One small game, end to end.** `engine.physics` over Box3D, audio, a Linux + Windows build pipeline. A 20-minute game, shipped as an executable. | The build runs on both platforms with no editor plugins in the shipped binary. |
| **M4** | **One small game, end to end — done when met, game itself in progress.** `engine.physics` (Box3D) and `engine.audio` (miniaudio) done, both over a narrow, scalars-only P/Invoke shim; `samples/PhysicsDemo` ties physics/audio/input/render together with `engine.editor` deliberately excluded from its `project.json`. `.github/workflows/build.yml` builds both native shims from source, tests, and headless-verifies on real `ubuntu-latest`/`windows-latest` runners — both green on the first real run, which itself caught two real cross-platform bugs no local testing could have (a locally-built `.so` too new for `ubuntu-latest`'s glibc; `--headless` not stopping a loaded `engine.render` from demanding a real GL context on a GPU-less Windows runner — see README's Status section). Not yet: the demo is a physics sandbox, not yet a scored 20-minute game. | The build runs on both platforms with no editor plugins in the shipped binary. |
---
+14
View File
@@ -0,0 +1,14 @@
cmake_minimum_required(VERSION 3.16)
project(lingua_audio_native C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
add_library(lingua_audio SHARED lingua_audio.c)
if(UNIX AND NOT APPLE)
find_package(Threads REQUIRED)
target_link_libraries(lingua_audio PRIVATE Threads::Threads m dl)
endif()
install(TARGETS lingua_audio RUNTIME DESTINATION . LIBRARY DESTINATION .)
+169
View File
@@ -0,0 +1,169 @@
// The same narrow-shim approach as native/physics-native/lingua_physics.c,
// for the same reason: miniaudio's own ma_engine_config/ma_sound_config are
// large structs with optional callbacks, not something worth replicating
// byte-for-byte in C#. Every exported function here takes and returns only
// plain int/float/bool scalars (a C string for the one thing that has to
// be one, a file path), and handles are this shim's own array-index
// handles into a fixed-capacity ma_sound table — not miniaudio's own
// pointer types.
//
// One global ma_engine: nothing in M4's scope needs more than one audio
// device active at a time.
#include <stdbool.h>
#define MINIAUDIO_IMPLEMENTATION
#include "miniaudio.h"
#if defined( _WIN32 )
#define LINGUA_API __declspec( dllexport )
#else
#define LINGUA_API __attribute__( ( visibility( "default" ) ) )
#endif
#define LINGUA_MAX_SOUNDS 64
static ma_engine g_engine;
static int g_engineInitialized = 0;
static ma_sound g_sounds[LINGUA_MAX_SOUNDS];
static int g_soundUsed[LINGUA_MAX_SOUNDS];
static int ValidSound( int handle )
{
return handle >= 0 && handle < LINGUA_MAX_SOUNDS && g_soundUsed[handle];
}
// useNullBackend: real playback needs a real device, which automated
// tests shouldn't depend on (nondeterministic across machines, and CI
// almost never has one) or want (nobody expects a test run to make sound).
// The null backend runs the exact same mixing/decoding/looping pipeline
// against a device that discards its output — real coverage of this
// shim's logic, silently.
LINGUA_API int Lingua_Audio_Init( int useNullBackend )
{
if ( g_engineInitialized )
return 1;
ma_result result;
if ( useNullBackend )
{
static ma_context context;
ma_backend backends[] = { ma_backend_null };
result = ma_context_init( backends, 1, NULL, &context );
if ( result != MA_SUCCESS )
return 0;
ma_engine_config engineConfig = ma_engine_config_init();
engineConfig.pContext = &context;
result = ma_engine_init( &engineConfig, &g_engine );
}
else
{
result = ma_engine_init( NULL, &g_engine );
}
if ( result != MA_SUCCESS )
return 0;
g_engineInitialized = 1;
return 1;
}
LINGUA_API void Lingua_Audio_Shutdown( void )
{
if ( !g_engineInitialized )
return;
for ( int i = 0; i < LINGUA_MAX_SOUNDS; i++ )
{
if ( g_soundUsed[i] )
{
ma_sound_uninit( &g_sounds[i] );
g_soundUsed[i] = 0;
}
}
ma_engine_uninit( &g_engine );
g_engineInitialized = 0;
}
LINGUA_API int Lingua_Audio_LoadSound( const char* path )
{
if ( !g_engineInitialized )
return -1;
int slot = -1;
for ( int i = 0; i < LINGUA_MAX_SOUNDS; i++ )
{
if ( !g_soundUsed[i] )
{
slot = i;
break;
}
}
if ( slot < 0 )
return -1;
if ( ma_sound_init_from_file( &g_engine, path, 0, NULL, NULL, &g_sounds[slot] ) != MA_SUCCESS )
return -1;
g_soundUsed[slot] = 1;
return slot;
}
LINGUA_API void Lingua_Audio_UnloadSound( int handle )
{
if ( !ValidSound( handle ) )
return;
ma_sound_uninit( &g_sounds[handle] );
g_soundUsed[handle] = 0;
}
// Always restarts from the first frame — the predictable behavior for
// "trigger this sound effect now." A looping music track only needs this
// called once; calling it again mid-loop is a deliberate restart, not a
// bug.
LINGUA_API void Lingua_Audio_Play( int handle )
{
if ( !ValidSound( handle ) )
return;
ma_sound_seek_to_pcm_frame( &g_sounds[handle], 0 );
ma_sound_start( &g_sounds[handle] );
}
LINGUA_API void Lingua_Audio_Stop( int handle )
{
if ( !ValidSound( handle ) )
return;
ma_sound_stop( &g_sounds[handle] );
}
LINGUA_API void Lingua_Audio_SetVolume( int handle, float volume )
{
if ( !ValidSound( handle ) )
return;
ma_sound_set_volume( &g_sounds[handle], volume );
}
LINGUA_API void Lingua_Audio_SetLooping( int handle, bool loop )
{
if ( !ValidSound( handle ) )
return;
ma_sound_set_looping( &g_sounds[handle], loop ? MA_TRUE : MA_FALSE );
}
LINGUA_API bool Lingua_Audio_IsPlaying( int handle )
{
if ( !ValidSound( handle ) )
return false;
return ma_sound_is_playing( &g_sounds[handle] ) ? true : false;
}
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
cmake_minimum_required(VERSION 3.16)
project(lingua_physics_native C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
# Box3D built static and folded into our own shared library, so the engine
# ships and P/Invokes exactly one native file (lingua_physics.so/.dll), not
# two. Pinned to a specific commit, not a tag or branch: Box3D has no 1.0
# release yet and its API is still moving (confirmed by diffing its only
# tag, v0.1.0, against this commit — real, not hypothetical, drift), so a
# floating reference would silently start building against a different API
# than the one this shim's code was actually written and verified against.
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(BOX3D_SAMPLES OFF CACHE BOOL "" FORCE)
set(BOX3D_UNIT_TESTS OFF CACHE BOOL "" FORCE)
set(BOX3D_BENCHMARKS OFF CACHE BOOL "" FORCE)
set(BOX3D_DOCS OFF CACHE BOOL "" FORCE)
include(FetchContent)
FetchContent_Declare(
box3d
GIT_REPOSITORY https://github.com/erincatto/box3d.git
GIT_TAG 47d7f7cc7e091142c08d11dc7d2e493c5d34f536
)
FetchContent_MakeAvailable(box3d)
add_library(lingua_physics SHARED lingua_physics.c)
target_link_libraries(lingua_physics PRIVATE box3d)
install(TARGETS lingua_physics RUNTIME DESTINATION . LIBRARY DESTINATION .)
+243
View File
@@ -0,0 +1,243 @@
// A deliberately narrow C shim between Box3D's real API and P/Invoke.
//
// Box3D's own API is not safe to bind directly from C#: b3WorldDef,
// b3BodyDef, and b3ShapeDef are large structs with function pointers and
// nested types passed by value, and b3BoxHull is a struct whose own doc
// comment says "has data hanging off the end and cannot be directly
// copied." Replicating any of that layout byte-for-byte in C# is exactly
// the kind of "compiles, passes a smoke test, breaks on someone else's
// machine" risk docs/kernel-contract.md's own risk table warns about for
// native interop specifically. So none of it crosses the P/Invoke
// boundary: every exported function here takes and returns only plain
// int32_t/float scalars, which marshal automatically with no `unsafe` on
// the C# side at all.
//
// Handles are plain array indices into fixed-capacity tables, not Box3D's
// own id structs — one less thing to get the marshaling of exactly right,
// and plenty of headroom for an indie-scale game (Box3D itself caps out at
// 128 worlds; nothing here needs more than one at a time yet).
#include <stdbool.h>
#include <stdint.h>
#include <box3d/box3d.h>
#if defined( _WIN32 )
#define LINGUA_API __declspec( dllexport )
#else
#define LINGUA_API __attribute__( ( visibility( "default" ) ) )
#endif
#define LINGUA_MAX_WORLDS 8
#define LINGUA_MAX_BODIES 8192
static b3WorldId g_worlds[LINGUA_MAX_WORLDS];
static bool g_worldUsed[LINGUA_MAX_WORLDS];
static b3BodyId g_bodies[LINGUA_MAX_BODIES];
static bool g_bodyUsed[LINGUA_MAX_BODIES];
static bool ValidWorld( int32_t handle )
{
return handle >= 0 && handle < LINGUA_MAX_WORLDS && g_worldUsed[handle];
}
static bool ValidBody( int32_t handle )
{
return handle >= 0 && handle < LINGUA_MAX_BODIES && g_bodyUsed[handle];
}
static int32_t AllocBodySlot( void )
{
for ( int32_t i = 0; i < LINGUA_MAX_BODIES; i++ )
{
if ( !g_bodyUsed[i] )
return i;
}
return -1;
}
LINGUA_API int32_t Lingua_CreateWorld( float gravityX, float gravityY, float gravityZ )
{
for ( int32_t i = 0; i < LINGUA_MAX_WORLDS; i++ )
{
if ( g_worldUsed[i] )
continue;
b3WorldDef def = b3DefaultWorldDef();
def.gravity = ( b3Vec3 ){ gravityX, gravityY, gravityZ };
g_worlds[i] = b3CreateWorld( &def );
g_worldUsed[i] = true;
return i;
}
return -1;
}
LINGUA_API void Lingua_DestroyWorld( int32_t worldHandle )
{
if ( !ValidWorld( worldHandle ) )
return;
b3DestroyWorld( g_worlds[worldHandle] );
g_worldUsed[worldHandle] = false;
}
// Exists so C# can assert on the native side's own bookkeeping directly —
// PhysicsWorld's C#-side _bodies dictionary can look correct while the
// native table underneath it has leaked a body Sync should have destroyed
// (see PhysicsWorldTests for the regression this catches).
LINGUA_API int32_t Lingua_GetBodyCount( void )
{
int32_t count = 0;
for ( int32_t i = 0; i < LINGUA_MAX_BODIES; i++ )
{
if ( g_bodyUsed[i] )
count++;
}
return count;
}
LINGUA_API void Lingua_WorldStep( int32_t worldHandle, float timeStep, int32_t subStepCount )
{
if ( !ValidWorld( worldHandle ) )
return;
b3World_Step( g_worlds[worldHandle], timeStep, subStepCount );
}
static int32_t CreateBody(
int32_t worldHandle,
float px, float py, float pz,
float qx, float qy, float qz, float qw,
int32_t bodyType )
{
if ( !ValidWorld( worldHandle ) )
return -1;
int32_t slot = AllocBodySlot();
if ( slot < 0 )
return -1;
b3BodyDef bodyDef = b3DefaultBodyDef();
bodyDef.type = (b3BodyType)bodyType;
bodyDef.position = ( b3Vec3 ){ px, py, pz };
bodyDef.rotation = ( b3Quat ){ { qx, qy, qz }, qw };
g_bodies[slot] = b3CreateBody( g_worlds[worldHandle], &bodyDef );
g_bodyUsed[slot] = true;
return slot;
}
static void ShapeDef( float density, float friction, float restitution, b3ShapeDef* def )
{
*def = b3DefaultShapeDef();
def->density = density;
def->baseMaterial.friction = friction;
def->baseMaterial.restitution = restitution;
}
LINGUA_API int32_t Lingua_CreateBoxBody(
int32_t worldHandle,
float px, float py, float pz,
float qx, float qy, float qz, float qw,
float halfWidth, float halfHeight, float halfDepth,
int32_t bodyType, float density, float friction, float restitution )
{
int32_t handle = CreateBody( worldHandle, px, py, pz, qx, qy, qz, qw, bodyType );
if ( handle < 0 )
return -1;
b3ShapeDef shapeDef;
ShapeDef( density, friction, restitution, &shapeDef );
b3BoxHull hull = b3MakeBoxHull( halfWidth, halfHeight, halfDepth );
b3CreateHullShape( g_bodies[handle], &shapeDef, &hull.base );
return handle;
}
LINGUA_API int32_t Lingua_CreateSphereBody(
int32_t worldHandle,
float px, float py, float pz,
float qx, float qy, float qz, float qw,
float radius,
int32_t bodyType, float density, float friction, float restitution )
{
int32_t handle = CreateBody( worldHandle, px, py, pz, qx, qy, qz, qw, bodyType );
if ( handle < 0 )
return -1;
b3ShapeDef shapeDef;
ShapeDef( density, friction, restitution, &shapeDef );
b3Sphere sphere = { ( b3Vec3 ){ 0, 0, 0 }, radius };
b3CreateSphereShape( g_bodies[handle], &shapeDef, &sphere );
return handle;
}
LINGUA_API void Lingua_DestroyBody( int32_t bodyHandle )
{
if ( !ValidBody( bodyHandle ) )
return;
b3DestroyBody( g_bodies[bodyHandle] );
g_bodyUsed[bodyHandle] = false;
}
LINGUA_API void Lingua_GetBodyTransform(
int32_t bodyHandle,
float* outPx, float* outPy, float* outPz,
float* outQx, float* outQy, float* outQz, float* outQw )
{
if ( !ValidBody( bodyHandle ) )
return;
b3WorldTransform t = b3Body_GetTransform( g_bodies[bodyHandle] );
*outPx = t.p.x;
*outPy = t.p.y;
*outPz = t.p.z;
*outQx = t.q.v.x;
*outQy = t.q.v.y;
*outQz = t.q.v.z;
*outQw = t.q.s;
}
LINGUA_API void Lingua_SetBodyTransform(
int32_t bodyHandle,
float px, float py, float pz,
float qx, float qy, float qz, float qw )
{
if ( !ValidBody( bodyHandle ) )
return;
b3Body_SetTransform( g_bodies[bodyHandle], ( b3Pos ){ px, py, pz }, ( b3Quat ){ { qx, qy, qz }, qw } );
}
LINGUA_API void Lingua_ApplyLinearImpulse( int32_t bodyHandle, float ix, float iy, float iz, bool wake )
{
if ( !ValidBody( bodyHandle ) )
return;
b3Body_ApplyLinearImpulseToCenter( g_bodies[bodyHandle], ( b3Vec3 ){ ix, iy, iz }, wake );
}
LINGUA_API void Lingua_GetLinearVelocity( int32_t bodyHandle, float* outVx, float* outVy, float* outVz )
{
if ( !ValidBody( bodyHandle ) )
return;
b3Vec3 v = b3Body_GetLinearVelocity( g_bodies[bodyHandle] );
*outVx = v.x;
*outVy = v.y;
*outVz = v.z;
}
LINGUA_API void Lingua_SetLinearVelocity( int32_t bodyHandle, float vx, float vy, float vz )
{
if ( !ValidBody( bodyHandle ) )
return;
b3Body_SetLinearVelocity( g_bodies[bodyHandle], ( b3Vec3 ){ vx, vy, vz } );
}
@@ -84,6 +84,16 @@ internal sealed class AssetService(IEventBus events, ILogger log) : IAssetServic
{
await Task.Delay(50);
}
catch (IOException ex)
{
// The 5th and final attempt — the `when` guard above only
// covers attempts 0-3, so this is the one case that used
// to fall out of the loop and propagate from a
// fire-and-forget Task (`_ = Task.Run(...)`) as an
// unobserved exception: no log, no event, nothing to show
// the reload silently never happened.
log.Warn($"Giving up reloading '{path}' after 5 attempts: {ex.Message}");
}
}
}
@@ -0,0 +1,18 @@
using Engine.Kernel.World;
namespace Engine.Audio.Contracts;
/// <summary>
/// A sound clip attached to a GameObject. ClipPath is loaded lazily, the
/// first time engine.audio sees this component — not eagerly on
/// AddComponent, since Component construction has no plugin context to
/// load anything through (same reason texture loading in engine.render
/// happens in a system, not a component constructor).
/// </summary>
public sealed class AudioSource : Component
{
public string ClipPath = "";
public float Volume = 1f;
public bool Loop = false;
public bool PlayOnAwake = false;
}
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,18 @@
using Engine.Kernel.World;
namespace Engine.Audio.Contracts;
/// <summary>
/// Gameplay-facing trigger for a GameObject's AudioSource — "play the
/// bounce sound now," not a field on AudioSource itself, same split
/// IPhysicsService makes between a Rigidbody's persistent data and an
/// action performed on it (see that interface's own doc comment).
/// </summary>
public interface IAudioService
{
void Play(GameObject go);
void Stop(GameObject go);
bool IsPlaying(GameObject go);
}
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Engine.Audio.Tests")]
@@ -0,0 +1,38 @@
using Engine.Audio.Contracts;
using Engine.Kernel.Plugins;
using Engine.Kernel.Scheduling;
using Engine.Kernel.World;
namespace Engine.Audio;
/// <summary>
/// M4's audio plugin: miniaudio, over the shim in native/audio-native/ —
/// see that file's own doc comment for why nothing but scalars and a UTF-8
/// path string cross the P/Invoke boundary. One system, on Stage.Update
/// (not FixedUpdate — nothing about audio is physics-timed): sync new/
/// changed/removed AudioSources every frame.
/// </summary>
public sealed class AudioPlugin : IPlugin
{
private AudioWorld? _world;
public void Configure(IPluginContext ctx)
{
_world = new AudioWorld(ctx.Log, useNullBackend: false);
ctx.Services.Provide<IAudioService>(new AudioService(_world));
ctx.Schedule.Add(Stage.Update, Sync).Reads<AudioSource>();
ctx.Log.Info("audio engine ready (miniaudio)");
}
public void Shutdown(IPluginContext ctx)
{
ctx.Schedule.RemoveAllFrom("engine.audio");
ctx.Services.Revoke<IAudioService>();
_world?.Dispose();
_world = null;
}
private void Sync(IWorld world) => _world!.Sync(world);
}
@@ -0,0 +1,13 @@
using Engine.Audio.Contracts;
using Engine.Kernel.World;
namespace Engine.Audio;
internal sealed class AudioService(AudioWorld world) : IAudioService
{
public void Play(GameObject go) => world.Play(go);
public void Stop(GameObject go) => world.Stop(go);
public bool IsPlaying(GameObject go) => world.IsPlaying(go);
}
@@ -0,0 +1,111 @@
using Engine.Audio.Contracts;
using Engine.Kernel.Diagnostics;
using Engine.Kernel.World;
namespace Engine.Audio;
/// <summary>
/// Owns the one native miniaudio engine this plugin creates, and the
/// GameObject &lt;-&gt; loaded-sound handle mapping. <see cref="Sync"/> loads a
/// clip the first time it sees a GameObject's AudioSource, applies
/// Volume/Loop changes without re-touching the native side when nothing
/// changed, fires PlayOnAwake exactly once, and — same no-destruction-event
/// problem PhysicsWorld has — unloads sounds for GameObjects that no
/// longer have an AudioSource.
///
/// useNullBackend exists for exactly one reason: Engine.Audio.Tests uses
/// it. Real gameplay always gets a real device (see AudioPlugin) — nothing
/// about the null backend belongs in a shipped build's own choice.
/// </summary>
internal sealed class AudioWorld : IDisposable
{
private readonly ILogger _log;
private readonly Dictionary<GameObject, int> _handles = [];
private readonly Dictionary<GameObject, (float Volume, bool Loop)> _lastSynced = [];
private readonly HashSet<GameObject> _warnedFailedToLoad = [];
public AudioWorld(ILogger log, bool useNullBackend)
{
_log = log;
if (Native.Lingua_Audio_Init(useNullBackend ? 1 : 0) == 0)
_log.Error("Failed to initialize the audio engine — no sounds will play.");
}
public void Sync(IWorld world)
{
var live = new HashSet<GameObject>();
foreach (var go in world.Query<AudioSource>())
{
live.Add(go);
var src = go.GetComponent<AudioSource>()!;
if (_warnedFailedToLoad.Contains(go))
continue;
if (!_handles.TryGetValue(go, out var handle))
{
handle = Native.Lingua_Audio_LoadSound(src.ClipPath);
if (handle < 0)
{
_log.Warn($"'{go.Name}' AudioSource failed to load '{src.ClipPath}'.");
_warnedFailedToLoad.Add(go);
continue;
}
_handles[go] = handle;
Native.Lingua_Audio_SetVolume(handle, src.Volume);
Native.Lingua_Audio_SetLooping(handle, src.Loop);
_lastSynced[go] = (src.Volume, src.Loop);
if (src.PlayOnAwake)
Native.Lingua_Audio_Play(handle);
continue;
}
var last = _lastSynced[go];
if (last.Volume != src.Volume)
Native.Lingua_Audio_SetVolume(handle, src.Volume);
if (last.Loop != src.Loop)
Native.Lingua_Audio_SetLooping(handle, src.Loop);
_lastSynced[go] = (src.Volume, src.Loop);
}
foreach (var stale in _handles.Keys.Where(go => !live.Contains(go)).ToList())
{
Native.Lingua_Audio_UnloadSound(_handles[stale]);
_handles.Remove(stale);
_lastSynced.Remove(stale);
}
_warnedFailedToLoad.RemoveWhere(go => !live.Contains(go));
}
public void Play(GameObject go)
{
if (_handles.TryGetValue(go, out var handle))
Native.Lingua_Audio_Play(handle);
}
public void Stop(GameObject go)
{
if (_handles.TryGetValue(go, out var handle))
Native.Lingua_Audio_Stop(handle);
}
public bool IsPlaying(GameObject go) =>
_handles.TryGetValue(go, out var handle) && Native.Lingua_Audio_IsPlaying(handle);
public void Dispose()
{
foreach (var handle in _handles.Values)
Native.Lingua_Audio_UnloadSound(handle);
_handles.Clear();
_lastSynced.Clear();
Native.Lingua_Audio_Shutdown();
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
<!-- Same reasoning as engine.physics' own native Content item — see
there for why linux-x64 is committed and win-x64 isn't. -->
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Linux'))">
<Content Include="native/linux-x64/liblingua_audio.so">
<Link>liblingua_audio.so</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
<Content Include="native/win-x64/lingua_audio.dll">
<Link>lingua_audio.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
<ProjectReference Include="..\Engine.Audio.Contracts\Engine.Audio.Contracts.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,43 @@
using System.Runtime.InteropServices;
namespace Engine.Audio;
/// <summary>
/// Matches native/audio-native/lingua_audio.c's exports one-to-one. Same
/// choice as engine.physics' Native.cs: classic DllImport, not
/// LibraryImport, so no AllowUnsafeBlocks is needed anywhere in this
/// plugin — every parameter here is a plain scalar or a UTF-8 string,
/// nothing that needs unsafe marshalling code.
/// </summary>
internal static class Native
{
private const string Lib = "lingua_audio";
[DllImport(Lib)]
public static extern int Lingua_Audio_Init(int useNullBackend);
[DllImport(Lib)]
public static extern void Lingua_Audio_Shutdown();
[DllImport(Lib)]
public static extern int Lingua_Audio_LoadSound([MarshalAs(UnmanagedType.LPUTF8Str)] string path);
[DllImport(Lib)]
public static extern void Lingua_Audio_UnloadSound(int handle);
[DllImport(Lib)]
public static extern void Lingua_Audio_Play(int handle);
[DllImport(Lib)]
public static extern void Lingua_Audio_Stop(int handle);
[DllImport(Lib)]
public static extern void Lingua_Audio_SetVolume(int handle, float volume);
[DllImport(Lib)]
public static extern void Lingua_Audio_SetLooping(int handle, [MarshalAs(UnmanagedType.U1)] bool loop);
[DllImport(Lib)]
[return: MarshalAs(UnmanagedType.U1)]
public static extern bool Lingua_Audio_IsPlaying(int handle);
}
+8
View File
@@ -0,0 +1,8 @@
{
"id": "engine.audio",
"version": "0.1.0",
"contracts": "Engine.Audio.Contracts.dll",
"assembly": "Engine.Audio.dll",
"dependsOn": {},
"reloadable": true
}
@@ -37,6 +37,7 @@ public sealed class EditorPlugin : IPlugin
private PlayModeController? _playMode;
private IEngineWindow? _window;
private ICameraService? _camera;
private bool _wasPlaying;
public void Configure(IPluginContext ctx)
{
@@ -107,10 +108,50 @@ public sealed class EditorPlugin : IPlugin
ImGui.Text(_playMode.IsPlaying ? "(Playing)" : "(Edit mode)");
ImGui.End();
// Found by independent review: ExitPlay's Restore destroys every
// GameObject and rebuilds fresh instances from the snapshot, but
// EditorState.Selected kept pointing at the old, now-detached one
// — Inspector and the gizmo would silently keep editing an object
// no longer in the world. Comparing against last frame's own
// IsPlaying (not just reacting to the button above) catches this
// uniformly regardless of *how* Play exited — the Stop button
// here, or the "stop" stdin command Engine.Host already processed
// before this Render-stage system ran this frame; the button
// alone would miss the second path entirely, the same
// stdin-vs-real-control gap already hit once this session.
if (_wasPlaying && !_playMode.IsPlaying)
{
var previousName = _state.Selected?.Name;
_state.Selected = previousName is null ? null : FindByName(world.Roots, previousName);
}
_wasPlaying = _playMode.IsPlaying;
HierarchyPanel.Draw(world, _state);
InspectorPanel.Draw(_state);
_gizmo.Draw(_state, _camera!, _window!);
_controller.Render();
}
// Best-effort by name, not identity — Restore rebuilds fresh
// GameObject instances, so there's no identity to match against
// anymore. Two siblings sharing a name (nothing stops it, same caveat
// HierarchyPanel's own PushID-by-hashcode already documents) means
// this picks the first match, not necessarily "the same one" — an
// acceptable approximation for reselecting after Play, not a
// guarantee.
private static GameObject? FindByName(IReadOnlyList<GameObject> roots, string name)
{
foreach (var go in roots)
{
if (go.Name == name)
return go;
var found = FindByName(go.Children, name);
if (found is not null)
return found;
}
return null;
}
}
@@ -7,7 +7,10 @@ namespace Engine.Editor;
/// Hierarchy panel last clicked, so the Inspector panel (built against this
/// same instance) knows what to show. One instance per EditorPlugin, not
/// static: reloading engine.editor should start with nothing selected, not
/// hold a reference to a GameObject that may not even exist anymore.
/// hold a reference to a GameObject that may not even exist anymore. The
/// same staleness can happen without a reload too — see EditorPlugin.
/// DrawUi's IsPlaying-transition check, which re-resolves this after
/// ExitPlay's Restore replaces every GameObject with a fresh instance.
/// </summary>
internal sealed class EditorState
{
@@ -0,0 +1,13 @@
namespace Engine.Physics.Contracts;
/// <summary>
/// Values match Box3D's own b3BodyType exactly (Static=0, Kinematic=1,
/// Dynamic=2) so engine.physics can cast this straight into the native
/// shim's bodyType parameter with no translation table to keep in sync.
/// </summary>
public enum BodyType
{
Static = 0,
Kinematic = 1,
Dynamic = 2,
}
@@ -0,0 +1,15 @@
using System.Numerics;
using Engine.Kernel.World;
namespace Engine.Physics.Contracts;
/// <summary>
/// A box collision shape, sized in half-extents (distance from center to
/// each face) — Box3D's own convention (b3MakeBoxHull's hx/hy/hz), kept
/// as-is rather than doubled into a "full size" field so there's no /2
/// conversion to get subtly wrong between here and the native shim.
/// </summary>
public sealed class BoxCollider : Component
{
public Vector3 HalfExtents = new(0.5f, 0.5f, 0.5f);
}
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,21 @@
using System.Numerics;
using Engine.Kernel.World;
namespace Engine.Physics.Contracts;
/// <summary>
/// Gameplay-facing physics actions — impulses and velocity — for a
/// GameObject that already has a Rigidbody. Separate from just poking
/// Rigidbody's own fields because these aren't state the Inspector should
/// show as persistent data (a velocity is a live simulation value, not
/// something a scene file should round-trip) and because applying an
/// impulse isn't a field write at all, it's an action Box3D performs.
/// </summary>
public interface IPhysicsService
{
void ApplyLinearImpulse(GameObject go, Vector3 impulse, bool wake = true);
Vector3 GetLinearVelocity(GameObject go);
void SetLinearVelocity(GameObject go, Vector3 velocity);
}
@@ -0,0 +1,25 @@
using Engine.Kernel.World;
namespace Engine.Physics.Contracts;
/// <summary>
/// Marks a GameObject as physics-simulated. Needs a BoxCollider or
/// SphereCollider on the same GameObject to actually get a body — Rigidbody
/// alone describes how it moves, not what shape it collides as, same split
/// Box3D itself makes between a body and its shapes.
///
/// Physics-enabled GameObjects must be root-level (no parent) for now:
/// PhysicsWorld writes Box3D's world-space transform straight into
/// Transform.LocalPosition/LocalRotation, which is only correct when local
/// and world space are the same thing. A parented rigidbody would need the
/// same parent-WorldMatrix-inverse handling GizmoMath.WorldToLocalPosition
/// already does for the gizmo — real work, not done here because nothing
/// in M4's demo needs a physics object with a parent yet.
/// </summary>
public sealed class Rigidbody : Component
{
public BodyType Type = BodyType.Dynamic;
public float Density = 1f;
public float Friction = 0.5f;
public float Restitution = 0.1f;
}
@@ -0,0 +1,8 @@
using Engine.Kernel.World;
namespace Engine.Physics.Contracts;
public sealed class SphereCollider : Component
{
public float Radius = 0.5f;
}
@@ -0,0 +1,7 @@
using System.Runtime.CompilerServices;
// PhysicsWorld is internal — nothing outside this plugin needs it, real
// physics behavior is tested against it directly rather than only through
// IPlugin/IPhysicsService's much narrower surface. Same pattern as
// Engine.Assets' PngReader.
[assembly: InternalsVisibleTo("Engine.Physics.Tests")]
@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Generates the deps.json AssemblyDependencyResolver needs to find
this plugin's dependencies — see PluginLoadContext in Engine.Kernel,
and the same property on the other native-facing plugins. -->
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
<!-- The compiled native shim (Box3D folded in) — see
native/physics-native/. Copied straight into this plugin's own
output directory (not a runtimes/<rid>/native/ NuGet-style path):
DllImport's default unmanaged-library probing already checks the
calling assembly's own directory, and PluginLoadContext.
LoadUnmanagedDll falls back to that default probing whenever its
AssemblyDependencyResolver doesn't recognize the name (returning
IntPtr.Zero, not failing outright) — confirmed empirically, see
PhysicsWorldTests.
Picked by the BUILDING machine's OS, not cross-compiled: linux-x64's
.so is committed (built and verified here); win-x64's .dll is never
committed — nothing here can build or run one to verify — and is
instead produced fresh by .github/workflows/build.yml's Windows job
right before `dotnet build` runs, landing at exactly this path. -->
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Linux'))">
<Content Include="native/linux-x64/liblingua_physics.so">
<Link>liblingua_physics.so</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
<Content Include="native/win-x64/lingua_physics.dll">
<Link>lingua_physics.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
<ProjectReference Include="..\Engine.Physics.Contracts\Engine.Physics.Contracts.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,78 @@
using System.Runtime.InteropServices;
namespace Engine.Physics;
/// <summary>
/// The entire P/Invoke surface, matching native/physics-native/
/// lingua_physics.c's exports one-to-one. Every parameter is a plain
/// int/float/bool — no struct crosses this boundary, on purpose, see that
/// file's own doc comment for why.
///
/// Classic DllImport, not the newer source-generated LibraryImport:
/// LibraryImport's generated marshalling code needs AllowUnsafeBlocks even
/// for an all-blittable signature like every one of these, and there's no
/// reason to open that door — DllImport marshals plain int/float/bool/out
/// float scalars with no unsafe code at all, which is what actually keeps
/// this plugin inside the kernel's "no unsafe in the v1 hot path" rule
/// (Directory.Build.props) instead of needing a narrow exception to it.
/// </summary>
internal static class Native
{
private const string Lib = "lingua_physics";
[DllImport(Lib)]
public static extern int Lingua_CreateWorld(float gravityX, float gravityY, float gravityZ);
[DllImport(Lib)]
public static extern void Lingua_DestroyWorld(int worldHandle);
[DllImport(Lib)]
public static extern void Lingua_WorldStep(int worldHandle, float timeStep, int subStepCount);
/// <summary>Total live bodies across every world — a test-only escape
/// hatch (see PhysicsWorldTests) to assert on the native side's own
/// bookkeeping, not just PhysicsWorld's C#-side dictionary.</summary>
[DllImport(Lib)]
public static extern int Lingua_GetBodyCount();
[DllImport(Lib)]
public static extern int Lingua_CreateBoxBody(
int worldHandle,
float px, float py, float pz,
float qx, float qy, float qz, float qw,
float halfWidth, float halfHeight, float halfDepth,
int bodyType, float density, float friction, float restitution);
[DllImport(Lib)]
public static extern int Lingua_CreateSphereBody(
int worldHandle,
float px, float py, float pz,
float qx, float qy, float qz, float qw,
float radius,
int bodyType, float density, float friction, float restitution);
[DllImport(Lib)]
public static extern void Lingua_DestroyBody(int bodyHandle);
[DllImport(Lib)]
public static extern void Lingua_GetBodyTransform(
int bodyHandle,
out float px, out float py, out float pz,
out float qx, out float qy, out float qz, out float qw);
[DllImport(Lib)]
public static extern void Lingua_SetBodyTransform(
int bodyHandle,
float px, float py, float pz,
float qx, float qy, float qz, float qw);
[DllImport(Lib)]
public static extern void Lingua_ApplyLinearImpulse(
int bodyHandle, float ix, float iy, float iz, [MarshalAs(UnmanagedType.U1)] bool wake);
[DllImport(Lib)]
public static extern void Lingua_GetLinearVelocity(int bodyHandle, out float vx, out float vy, out float vz);
[DllImport(Lib)]
public static extern void Lingua_SetLinearVelocity(int bodyHandle, float vx, float vy, float vz);
}
@@ -0,0 +1,54 @@
using System.Numerics;
using Engine.Kernel.Diagnostics;
using Engine.Kernel.Plugins;
using Engine.Kernel.Scheduling;
using Engine.Kernel.World;
using Engine.Physics.Contracts;
namespace Engine.Physics;
/// <summary>
/// M4's physics plugin: Box3D, over the shim in native/physics-native/ —
/// see that file's own doc comment for why nothing crosses the P/Invoke
/// boundary but plain scalars. One system, on Stage.FixedUpdate: sync new/
/// removed Rigidbodies, step once, write results back to Transform. Each
/// FixedUpdate invocation is exactly one physics step at ITime.
/// FixedDeltaTime — Engine.Host's accumulator (Time.ConsumeFixedSteps)
/// decides how many times that runs this frame, not this plugin.
/// </summary>
public sealed class PhysicsPlugin : IPlugin
{
private static readonly Vector3 Gravity = new(0f, -9.81f, 0f);
private PhysicsWorld? _world;
private ITime? _time;
public void Configure(IPluginContext ctx)
{
_time = ctx.Time;
_world = new PhysicsWorld(Gravity, ctx.Log);
ctx.Services.Provide<IPhysicsService>(new PhysicsService(_world));
ctx.Schedule.Add(Stage.FixedUpdate, Step)
.Reads<Rigidbody>()
.Reads<BoxCollider>()
.Reads<SphereCollider>();
ctx.Log.Info("physics world ready (Box3D)");
}
public void Shutdown(IPluginContext ctx)
{
ctx.Schedule.RemoveAllFrom("engine.physics");
ctx.Services.Revoke<IPhysicsService>();
_world?.Dispose();
_world = null;
_time = null;
}
private void Step(IWorld world)
{
_world!.Sync(world);
_world.Step(_time!.FixedDeltaTime);
}
}
@@ -0,0 +1,15 @@
using System.Numerics;
using Engine.Kernel.World;
using Engine.Physics.Contracts;
namespace Engine.Physics;
internal sealed class PhysicsService(PhysicsWorld world) : IPhysicsService
{
public void ApplyLinearImpulse(GameObject go, Vector3 impulse, bool wake = true) =>
world.ApplyLinearImpulse(go, impulse, wake);
public Vector3 GetLinearVelocity(GameObject go) => world.GetLinearVelocity(go);
public void SetLinearVelocity(GameObject go, Vector3 velocity) => world.SetLinearVelocity(go, velocity);
}
@@ -0,0 +1,153 @@
using System.Numerics;
using Engine.Kernel.Diagnostics;
using Engine.Kernel.World;
using Engine.Physics.Contracts;
namespace Engine.Physics;
/// <summary>
/// Owns the one native Box3D world this plugin creates, and the GameObject
/// &lt;-&gt; native body handle mapping. <see cref="Sync"/> creates bodies for
/// any GameObject that's grown a Rigidbody since last frame and destroys
/// bodies for any that lost one (component removed, or the GameObject
/// itself was destroyed) — there's no destruction event to hook, so this
/// diffs Query&lt;Rigidbody&gt;() against the tracked set every FixedUpdate
/// instead. <see cref="Step"/> advances the simulation once and writes
/// every tracked body's new transform back into its GameObject.
/// </summary>
internal sealed class PhysicsWorld : IDisposable
{
private readonly int _handle;
private readonly ILogger _log;
private readonly Dictionary<GameObject, int> _bodies = [];
private readonly HashSet<GameObject> _warnedFailed = [];
public PhysicsWorld(Vector3 gravity, ILogger log)
{
_log = log;
_handle = Native.Lingua_CreateWorld(gravity.X, gravity.Y, gravity.Z);
}
public void Sync(IWorld world)
{
var live = new HashSet<GameObject>();
foreach (var go in world.Query<Rigidbody>())
{
live.Add(go);
if (!_bodies.ContainsKey(go))
TryCreateBody(go);
}
// Comparing counts alone would miss this: destroying one tracked
// GameObject and gaining a different untracked one in the same
// Sync leaves _bodies.Count == live.Count with the sets actually
// different — the stale native body would never get destroyed and
// would keep simulating forever. Restore (every ExitPlay) hits
// this reliably whenever the scene has both a Rigidbody+collider
// GameObject and a Rigidbody-without-collider one, since the
// latter never enters _bodies to begin with.
var stale = _bodies.Keys.Where(go => !live.Contains(go)).ToList();
if (stale.Count == 0)
return;
foreach (var go in stale)
{
Native.Lingua_DestroyBody(_bodies[go]);
_bodies.Remove(go);
_warnedFailed.Remove(go);
}
}
public void Step(float timeStep, int subStepCount = 4)
{
Native.Lingua_WorldStep(_handle, timeStep, subStepCount);
foreach (var (go, handle) in _bodies)
{
Native.Lingua_GetBodyTransform(handle, out var px, out var py, out var pz, out var qx, out var qy, out var qz, out var qw);
var t = go.Transform;
t.LocalPosition = new Vector3(px, py, pz);
t.LocalRotation = new Quaternion(qx, qy, qz, qw);
go.Transform = t;
}
}
public void ApplyLinearImpulse(GameObject go, Vector3 impulse, bool wake)
{
if (_bodies.TryGetValue(go, out var handle))
Native.Lingua_ApplyLinearImpulse(handle, impulse.X, impulse.Y, impulse.Z, wake);
}
public Vector3 GetLinearVelocity(GameObject go)
{
if (!_bodies.TryGetValue(go, out var handle))
return Vector3.Zero;
Native.Lingua_GetLinearVelocity(handle, out var vx, out var vy, out var vz);
return new Vector3(vx, vy, vz);
}
public void SetLinearVelocity(GameObject go, Vector3 velocity)
{
if (_bodies.TryGetValue(go, out var handle))
Native.Lingua_SetLinearVelocity(handle, velocity.X, velocity.Y, velocity.Z);
}
private void TryCreateBody(GameObject go)
{
var rb = go.GetComponent<Rigidbody>()!;
var pos = go.Transform.LocalPosition;
var rot = go.Transform.LocalRotation;
var box = go.GetComponent<BoxCollider>();
var sphere = go.GetComponent<SphereCollider>();
int handle;
if (box is not null)
{
handle = Native.Lingua_CreateBoxBody(
_handle, pos.X, pos.Y, pos.Z, rot.X, rot.Y, rot.Z, rot.W,
box.HalfExtents.X, box.HalfExtents.Y, box.HalfExtents.Z,
(int)rb.Type, rb.Density, rb.Friction, rb.Restitution);
}
else if (sphere is not null)
{
handle = Native.Lingua_CreateSphereBody(
_handle, pos.X, pos.Y, pos.Z, rot.X, rot.Y, rot.Z, rot.W,
sphere.Radius, (int)rb.Type, rb.Density, rb.Friction, rb.Restitution);
}
else
{
if (_warnedFailed.Add(go))
_log.Warn($"'{go.Name}' has a Rigidbody but no BoxCollider/SphereCollider — no physics body created.");
return;
}
// -1 means the native shim refused (an invalid world handle, or
// its fixed-capacity body table — 8192 — is full). Storing it
// anyway would silently "work": every later Lingua_GetBodyTransform
// call on handle -1 fails ValidBody's check and leaves the out
// params at their P/Invoke-zeroed default, teleporting the
// GameObject to the origin with a degenerate all-zero rotation
// every FixedUpdate — no exception, no log, just a wrong position.
if (handle < 0)
{
if (_warnedFailed.Add(go))
_log.Warn($"'{go.Name}' failed to create a native physics body (world invalid or body table full).");
return;
}
_bodies[go] = handle;
}
public void Dispose()
{
foreach (var handle in _bodies.Values)
Native.Lingua_DestroyBody(handle);
_bodies.Clear();
Native.Lingua_DestroyWorld(_handle);
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"id": "engine.physics",
"version": "0.1.0",
"contracts": "Engine.Physics.Contracts.dll",
"assembly": "Engine.Physics.dll",
"dependsOn": {},
"reloadable": true
}
@@ -0,0 +1,12 @@
using Engine.Kernel.World;
namespace Engine.Render.Contracts;
/// <summary>
/// A unit cube (1x1x1 before Transform.LocalScale), same no-size-fields
/// reasoning as QuadRenderer — Transform.LocalScale already means "how
/// big." Added for M4's physics demo: a BoxCollider falling and settling
/// only reads as a real physics object on screen if it actually looks like
/// a box, not a flat card.
/// </summary>
public sealed class CubeRenderer : Component;
@@ -12,11 +12,14 @@ namespace Engine.Render;
/// <summary>
/// M3's render pipeline: a real perspective camera, drawing every
/// GameObject with a QuadRenderer at its own WorldMatrix — upgraded from
/// M2's single hardcoded NDC-space quad specifically because gizmos need
/// real 3D geometry and a real camera to mean anything (a handle dragged
/// in screen space has to map onto an actual 3D axis). See M3 in
/// docs/kernel-contract.md §8.
/// GameObject with a QuadRenderer or CubeRenderer at its own WorldMatrix —
/// upgraded from M2's single hardcoded NDC-space quad specifically because
/// gizmos need real 3D geometry and a real camera to mean anything (a
/// handle dragged in screen space has to map onto an actual 3D axis). See
/// M3 in docs/kernel-contract.md §8. CubeRenderer itself is M4's addition,
/// for the same reason M3's gizmo needed a real camera: a BoxCollider
/// falling and settling only reads as a physics object if it looks like
/// one, not a flat card.
///
/// GameObject.WorldMatrix and this plugin's own matrices are both
/// System.Numerics.Matrix4x4, which is row-vector (v' = v * M, and
@@ -95,11 +98,64 @@ public sealed class RenderPlugin : IPlugin
0.5f, 0.5f, 0f, 1f, 1f,
];
// Unit cube (1x1x1 before LocalScale), centered at its own origin — see
// CubeRenderer's own doc comment for why this exists (M4's physics
// demo needs falling BoxColliders to actually look like boxes). No
// face culling is enabled anywhere in this plugin, so winding order
// doesn't matter here the way it would with CullFace on.
private static readonly float[] CubeVertices =
[
// position uv
-0.5f, -0.5f, 0.5f, 0f, 0f, // front (+Z)
0.5f, -0.5f, 0.5f, 1f, 0f,
0.5f, 0.5f, 0.5f, 1f, 1f,
0.5f, 0.5f, 0.5f, 1f, 1f,
-0.5f, 0.5f, 0.5f, 0f, 1f,
-0.5f, -0.5f, 0.5f, 0f, 0f,
0.5f, -0.5f, -0.5f, 0f, 0f, // back (-Z)
-0.5f, -0.5f, -0.5f, 1f, 0f,
-0.5f, 0.5f, -0.5f, 1f, 1f,
-0.5f, 0.5f, -0.5f, 1f, 1f,
0.5f, 0.5f, -0.5f, 0f, 1f,
0.5f, -0.5f, -0.5f, 0f, 0f,
-0.5f, -0.5f, -0.5f, 0f, 0f, // left (-X)
-0.5f, -0.5f, 0.5f, 1f, 0f,
-0.5f, 0.5f, 0.5f, 1f, 1f,
-0.5f, 0.5f, 0.5f, 1f, 1f,
-0.5f, 0.5f, -0.5f, 0f, 1f,
-0.5f, -0.5f, -0.5f, 0f, 0f,
0.5f, -0.5f, 0.5f, 0f, 0f, // right (+X)
0.5f, -0.5f, -0.5f, 1f, 0f,
0.5f, 0.5f, -0.5f, 1f, 1f,
0.5f, 0.5f, -0.5f, 1f, 1f,
0.5f, 0.5f, 0.5f, 0f, 1f,
0.5f, -0.5f, 0.5f, 0f, 0f,
-0.5f, 0.5f, 0.5f, 0f, 0f, // top (+Y)
0.5f, 0.5f, 0.5f, 1f, 0f,
0.5f, 0.5f, -0.5f, 1f, 1f,
0.5f, 0.5f, -0.5f, 1f, 1f,
-0.5f, 0.5f, -0.5f, 0f, 1f,
-0.5f, 0.5f, 0.5f, 0f, 0f,
-0.5f, -0.5f, -0.5f, 0f, 0f, // bottom (-Y)
0.5f, -0.5f, -0.5f, 1f, 0f,
0.5f, -0.5f, 0.5f, 1f, 1f,
0.5f, -0.5f, 0.5f, 1f, 1f,
-0.5f, -0.5f, 0.5f, 0f, 1f,
-0.5f, -0.5f, -0.5f, 0f, 0f,
];
private GL? _gl;
private IEngineWindow? _window;
private CameraService? _camera;
private uint _vao;
private uint _vbo;
private uint _cubeVao;
private uint _cubeVbo;
private uint _program;
private uint _texture;
private int _modelLocation;
@@ -143,6 +199,20 @@ public sealed class RenderPlugin : IPlugin
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(1);
_gl.BindVertexArray(0);
_cubeVao = _gl.GenVertexArray();
_gl.BindVertexArray(_cubeVao);
_cubeVbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _cubeVbo);
_gl.BufferData<float>(BufferTargetARB.ArrayBuffer, CubeVertices, BufferUsageARB.StaticDraw);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(1);
_gl.BindVertexArray(0);
_gl.Enable(EnableCap.DepthTest);
@@ -159,7 +229,7 @@ public sealed class RenderPlugin : IPlugin
ctx.Events.Subscribe(_onTextureReloaded);
ctx.Services.Provide<IScreenCapture>(new GlScreenCapture(_gl, _window));
ctx.Schedule.Add(Stage.Render, Draw).Reads<QuadRenderer>();
ctx.Schedule.Add(Stage.Render, Draw).Reads<QuadRenderer>().Reads<CubeRenderer>();
ctx.Schedule.Add(Stage.Present, Present);
ctx.Log.Info("GL context created, 3D quad pipeline ready");
}
@@ -176,6 +246,8 @@ public sealed class RenderPlugin : IPlugin
_gl.DeleteTexture(_texture);
_gl.DeleteVertexArray(_vao);
_gl.DeleteBuffer(_vbo);
_gl.DeleteVertexArray(_cubeVao);
_gl.DeleteBuffer(_cubeVbo);
_gl.DeleteProgram(_program);
_gl.Dispose();
}
@@ -237,6 +309,13 @@ public sealed class RenderPlugin : IPlugin
SetMatrix(_modelLocation, go.WorldMatrix);
_gl.DrawArrays(PrimitiveType.Triangles, 0, 6);
}
_gl.BindVertexArray(_cubeVao);
foreach (var go in world.Query<CubeRenderer>())
{
SetMatrix(_modelLocation, go.WorldMatrix);
_gl.DrawArrays(PrimitiveType.Triangles, 0, 36);
}
}
// Split from Draw() into its own Stage.Present system so engine.editor
@@ -0,0 +1,41 @@
using Engine.Kernel.Plugins;
using Engine.Kernel.Scheduling;
using Engine.Kernel.World;
using Sandbox.Echo.Contracts;
namespace Sandbox.FailingConfigure;
/// <summary>
/// A test fixture, not a real plugin — exists only so
/// FailedConfigureRollbackTests can exercise PluginHost.Load's rollback
/// path against a real ALC/assembly load, the same way sandbox.echo exists
/// for the successful-load path. Registers a system that increments a
/// shared Ping component (so there's a real, deterministic side effect for
/// the test to check — did the dangling system actually get removed, not
/// just "did the ALC eventually get GC'd," which turned out to happen
/// either way regardless of whether rollback ran, making it useless as a
/// regression signal here), then throws — reproducing "Configure got
/// partway through before failing," not "Configure failed immediately."
/// </summary>
public sealed class FailingConfigurePlugin : IPlugin
{
public void Configure(IPluginContext ctx)
{
ctx.Schedule.Add(Stage.Update, Tick).Writes<Ping>();
ctx.Events.Subscribe<PluginLoaded>(_ => { });
throw new InvalidOperationException("deliberate failure for PluginHostTests");
}
public void Shutdown(IPluginContext ctx)
{
ctx.Schedule.RemoveAllFrom("sandbox.failing-configure");
ctx.Events.RemoveAllFrom("sandbox.failing-configure");
}
private static void Tick(IWorld world)
{
foreach (var go in world.Query<Ping>())
go.GetComponent<Ping>()!.Count++;
}
}
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
<!-- Reusing sandbox.echo's own Ping component, not defining a new one:
it's already a shared, cross-ALC-safe Default-ALC type, and gives
FailedConfigureRollbackTests a real, deterministic side effect to
assert on (Ping.Count) instead of a GC-timing-sensitive check. -->
<ProjectReference Include="..\..\sandbox.echo\Sandbox.Echo.Contracts\Sandbox.Echo.Contracts.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,7 @@
{
"id": "sandbox.failing-configure",
"version": "0.1.0",
"assembly": "Sandbox.FailingConfigure.dll",
"dependsOn": {},
"reloadable": true
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>PhysicsDemoGame</RootNamespace>
<AssemblyName>PhysicsDemoGame</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../../src/Engine.Kernel/Engine.Kernel.csproj" />
<ProjectReference Include="../../../../plugins/engine.input/Engine.Input.Contracts/Engine.Input.Contracts.csproj" />
<ProjectReference Include="../../../../plugins/engine.physics/Engine.Physics.Contracts/Engine.Physics.Contracts.csproj" />
<ProjectReference Include="../../../../plugins/engine.audio/Engine.Audio.Contracts/Engine.Audio.Contracts.csproj" />
<ProjectReference Include="../../../../plugins/engine.render/Engine.Render.Contracts/Engine.Render.Contracts.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,119 @@
using System.Numerics;
using Engine.Audio.Contracts;
using Engine.Input.Contracts;
using Engine.Kernel.Plugins;
using Engine.Kernel.Scheduling;
using Engine.Kernel.World;
using Engine.Physics.Contracts;
using Engine.Render.Contracts;
using Silk.NET.Input;
namespace PhysicsDemoGame;
/// <summary>
/// M4's "one small game, end to end": pressing Space drops a new box from
/// above the ground; engine.physics simulates it falling and settling;
/// engine.render draws it as a real cube (CubeRenderer, added specifically
/// for this); this plugin plays a bounce sound (engine.audio) the moment
/// each box's fall actually stops. Physics, audio, input, and rendering
/// all cooperate here through nothing but the kernel's own vocabulary —
/// this plugin never references engine.physics/audio/render's
/// implementation assemblies, only their Contracts.
///
/// "Landed" isn't a Box3D contact event — the native shim doesn't expose
/// one (see native/physics-native/lingua_physics.c's own doc comment on
/// why nothing but scalars crosses that boundary; a contact callback would
/// need to carry structured per-contact data across it, exactly what's
/// kept out). Honest scope for a demo this size: watch each spawned box's
/// own Y velocity via IPhysicsService every frame, and call it landed the
/// first time a real fall (velocity past FallingThreshold at some point)
/// settles back near zero.
/// </summary>
public sealed class PhysicsDemoGamePlugin : IPlugin
{
private const float SpawnHeight = 6f;
private const float FallingThreshold = -1f;
private const float RestThreshold = 0.3f;
private readonly Random _random = new();
private readonly List<GameObject> _spawned = [];
private readonly HashSet<GameObject> _hasFallen = [];
private readonly HashSet<GameObject> _landed = [];
private IEngineInput? _input;
private IPhysicsService? _physics;
private IAudioService? _audio;
private GameObject? _bounceSound;
private bool _spacePressedLastFrame;
public void Configure(IPluginContext ctx)
{
_input = ctx.Services.Require<IEngineInput>();
_physics = ctx.Services.Require<IPhysicsService>();
_audio = ctx.Services.Require<IAudioService>();
ctx.Schedule.Add(Stage.Update, Tick)
.Writes<Rigidbody>()
.Writes<BoxCollider>()
.Writes<CubeRenderer>();
ctx.Log.Info("physics demo ready — press Space to drop a box");
}
public void Shutdown(IPluginContext ctx)
{
ctx.Schedule.RemoveAllFrom("physics-demo-game");
_spawned.Clear();
_hasFallen.Clear();
_landed.Clear();
_bounceSound = null;
_input = null;
_physics = null;
_audio = null;
}
private void Tick(IWorld world)
{
_bounceSound ??= world.Roots.FirstOrDefault(go => go.Name == "Bounce");
var spacePressed = _input!.IsKeyDown(Key.Space);
if (spacePressed && !_spacePressedLastFrame)
Spawn(world);
_spacePressedLastFrame = spacePressed;
foreach (var box in _spawned)
{
if (_landed.Contains(box))
continue;
var velocityY = _physics!.GetLinearVelocity(box).Y;
if (velocityY < FallingThreshold)
{
_hasFallen.Add(box);
continue;
}
if (_hasFallen.Contains(box) && MathF.Abs(velocityY) < RestThreshold)
{
_landed.Add(box);
if (_bounceSound is not null)
_audio!.Play(_bounceSound);
}
}
}
private void Spawn(IWorld world)
{
var go = world.CreateGameObject($"Box{_spawned.Count}");
go.Transform = Transform.Identity;
go.Transform.LocalPosition = new Vector3(
(float)(_random.NextDouble() * 4 - 2), SpawnHeight, (float)(_random.NextDouble() * 4 - 2));
go.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
go.AddComponent<BoxCollider>();
go.AddComponent<CubeRenderer>();
_spawned.Add(go);
}
}
@@ -0,0 +1,12 @@
{
"id": "physics-demo-game",
"version": "0.1.0",
"assembly": "PhysicsDemoGame.dll",
"dependsOn": {
"engine.input": "^0.1",
"engine.physics": "^0.1",
"engine.audio": "^0.1",
"engine.render": "^0.1"
},
"reloadable": true
}
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 B

@@ -0,0 +1,7 @@
{
"engineVersion": "^0.1",
"plugins": [
{ "id": "engine.physics" }
],
"pluginPaths": []
}
+13
View File
@@ -0,0 +1,13 @@
{
"engineVersion": "^0.1",
"plugins": [
{ "id": "engine.windowing" },
{ "id": "engine.assets" },
{ "id": "engine.render" },
{ "id": "engine.input" },
{ "id": "engine.physics" },
{ "id": "engine.audio" },
{ "id": "physics-demo-game" }
],
"pluginPaths": ["GamePlugins"]
}
@@ -0,0 +1,20 @@
[
{
"name": "Ground",
"transform": { "position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
"components": [
{ "type": "Engine.Physics.Contracts.Rigidbody, Engine.Physics.Contracts", "data": { "Type": 0, "Friction": 0.6, "Restitution": 0.3 } },
{ "type": "Engine.Physics.Contracts.BoxCollider, Engine.Physics.Contracts", "data": { "HalfExtents": { "X": 4, "Y": 0.5, "Z": 4 } } }
],
"children": []
},
{
"name": "DemoBox",
"transform": { "position": [0, 3, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
"components": [
{ "type": "Engine.Physics.Contracts.Rigidbody, Engine.Physics.Contracts", "data": { "Type": 2, "Friction": 0.6, "Restitution": 0.3 } },
{ "type": "Engine.Physics.Contracts.BoxCollider, Engine.Physics.Contracts", "data": {} }
],
"children": []
}
]
+42
View File
@@ -0,0 +1,42 @@
[
{
"name": "Ground",
"transform": {
"position": [0, 0, 0],
"rotation": [0, 0, 0, 1],
"scale": [8, 1, 8]
},
"components": [
{ "type": "Engine.Physics.Contracts.Rigidbody, Engine.Physics.Contracts", "data": { "Type": 0, "Friction": 0.6, "Restitution": 0.3 } },
{ "type": "Engine.Physics.Contracts.BoxCollider, Engine.Physics.Contracts", "data": { "HalfExtents": { "X": 4, "Y": 0.5, "Z": 4 } } },
{ "type": "Engine.Render.Contracts.CubeRenderer, Engine.Render.Contracts", "data": {} }
],
"children": []
},
{
"name": "DemoBox",
"transform": { "position": [0, 3, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
"components": [
{ "type": "Engine.Physics.Contracts.Rigidbody, Engine.Physics.Contracts", "data": { "Type": 2, "Friction": 0.6, "Restitution": 0.3 } },
{ "type": "Engine.Physics.Contracts.BoxCollider, Engine.Physics.Contracts", "data": {} },
{ "type": "Engine.Render.Contracts.CubeRenderer, Engine.Render.Contracts", "data": {} }
],
"children": []
},
{
"name": "Music",
"transform": { "position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
"components": [
{ "type": "Engine.Audio.Contracts.AudioSource, Engine.Audio.Contracts", "data": { "ClipPath": "assets/music.wav", "Volume": 0.35, "Loop": true, "PlayOnAwake": true } }
],
"children": []
},
{
"name": "Bounce",
"transform": { "position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
"components": [
{ "type": "Engine.Audio.Contracts.AudioSource, Engine.Audio.Contracts", "data": { "ClipPath": "assets/bounce.wav", "Volume": 0.8, "Loop": false, "PlayOnAwake": false } }
],
"children": []
}
]
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Stages the shared engine plugins, builds+places the sample's own
# game-specific plugin (physics-demo-game, listed in this project's own
# pluginPaths rather than the shared engine catalog — it's this game's
# logic, not a reusable engine plugin), then runs samples/PhysicsDemo.
# Extra args are forwarded to `engine run`.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONFIG="${CONFIGURATION:-Debug}"
STAGE_DIR="$REPO_ROOT/.stage/plugins"
GAME_DIR="$REPO_ROOT/samples/PhysicsDemo/GamePlugins/physics-demo-game"
"$REPO_ROOT/scripts/stage-plugins.sh" "$STAGE_DIR"
cp "$GAME_DIR/bin/$CONFIG/net9.0/"*.dll "$GAME_DIR/" 2>/dev/null || true
cp "$GAME_DIR/bin/$CONFIG/net9.0/"*.pdb "$GAME_DIR/" 2>/dev/null || true
cd "$REPO_ROOT/samples/PhysicsDemo"
exec dotnet "$REPO_ROOT/src/Engine.Host/bin/$CONFIG/net9.0/Engine.Host.dll" run --windowed \
--plugins "$STAGE_DIR" --project project.json --scene scene.json "$@"
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Stages every plugin, then runs samples/WindowDemo windowed. Extra args
# are forwarded to `engine run` — e.g. ./run-sample.sh --screenshot out.png
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONFIG="${CONFIGURATION:-Debug}"
STAGE_DIR="$REPO_ROOT/.stage/plugins"
"$REPO_ROOT/scripts/stage-plugins.sh" "$STAGE_DIR"
cd "$REPO_ROOT/samples/WindowDemo"
exec dotnet "$REPO_ROOT/src/Engine.Host/bin/$CONFIG/net9.0/Engine.Host.dll" run --windowed \
--plugins "$STAGE_DIR" --project project.json --scene scene.json "$@"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Builds every plugin and lays each one out into a single flat directory
# per id — plugin.json alongside its built DLLs — which is the layout
# PluginHost.Load actually expects. Needed because Contracts and
# Implementation build into two separate bin/ folders; without this step
# there's nowhere valid to point --plugins at.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STAGE_DIR="${1:-$REPO_ROOT/.stage/plugins}"
CONFIG="${CONFIGURATION:-Debug}"
echo "Building solution ($CONFIG)..."
dotnet build "$REPO_ROOT/LinguaEngine.sln" -c "$CONFIG" >/dev/null
rm -rf "$STAGE_DIR"
mkdir -p "$STAGE_DIR"
for plugin_dir in "$REPO_ROOT"/plugins/*/; do
id="$(basename "$plugin_dir")"
[ -f "$plugin_dir/plugin.json" ] || continue
# engine.foo -> Engine.Foo (the Implementation project's folder name)
impl_name="$(awk -F. '{for(i=1;i<=NF;i++){$i=toupper(substr($i,1,1)) substr($i,2)}}1' OFS=. <<<"$id")"
impl_out="$plugin_dir/$impl_name/bin/$CONFIG/net9.0"
if [ ! -d "$impl_out" ]; then
echo "warning: no build output for '$id' at $impl_out, skipping" >&2
continue
fi
mkdir -p "$STAGE_DIR/$id"
cp -r "$impl_out/." "$STAGE_DIR/$id/"
cp "$plugin_dir/plugin.json" "$STAGE_DIR/$id/"
done
echo "Staged plugins into: $STAGE_DIR"
+29 -3
View File
@@ -230,13 +230,24 @@ if (windowed)
Console.WriteLine($"Reloading '{cmd.Argument}'...");
try
{
host.Unload(cmd.Argument);
// Not unconditional: a plugin whose previous
// reload already failed inside Load (a bad
// Configure — now rolled back, see PluginHost.
// Load) isn't loaded any more, and Unload would
// just throw "not loaded" on this retry instead of
// ever reaching Load again.
if (host.IsLoaded(cmd.Argument))
host.Unload(cmd.Argument);
host.Load(pluginDirectory);
Console.WriteLine($"Reloaded '{cmd.Argument}'. World state and the window were untouched.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"Failed to reload '{cmd.Argument}': {ex.Message}");
Console.Error.WriteLine(
$"Failed to reload '{cmd.Argument}': {ex.Message} " +
$"'{cmd.Argument}' is now unloaded (not just still running the old code) — " +
$"fix the error and run 'r {cmd.Argument}' again.");
}
break;
@@ -270,9 +281,19 @@ if (windowed)
// before this plugin existed. With one loaded, Update only runs
// while actually Playing: Edit mode still renders the scene every
// frame (so the editor UI stays responsive and the view isn't
// frozen mid-edit), it just never ticks it.
// frozen mid-edit), it just never ticks it. FixedUpdate is gated
// the same way and for the same reason — physics shouldn't step
// while Edit mode has the world frozen — and only accumulates time
// while playing, so Play doesn't open with a catch-up burst of
// steps for however long Edit mode had been sitting idle.
if (playMode is null || playMode.IsPlaying)
{
var fixedSteps = time.ConsumeFixedSteps();
for (var step = 0; step < fixedSteps; step++)
schedule.RunStage(Stage.FixedUpdate, world);
schedule.RunStage(Stage.Update, world);
}
schedule.RunStage(Stage.Render, world);
schedule.RunStage(Stage.Present, world);
@@ -313,6 +334,11 @@ else
for (var frame = 0; frame < frames; frame++)
{
time.Tick(headlessDeltaTime);
var fixedSteps = time.ConsumeFixedSteps();
for (var step = 0; step < fixedSteps; step++)
schedule.RunStage(Stage.FixedUpdate, world);
schedule.RunStage(Stage.Update, world);
}
+14 -7
View File
@@ -3,13 +3,12 @@ namespace Engine.Kernel.Diagnostics;
/// <summary>
/// Frame clock. See docs/kernel-contract.md §2.
///
/// No fixed-step accumulator yet — that was in the original kernel scope
/// but nothing consumes it: building it now, with no physics system to
/// test it against, would be exactly the kind of untested speculative
/// machinery this project has avoided everywhere else. It arrives with
/// M4, alongside the Stage.FixedUpdate it would drive — a "FixedUpdate"
/// stage without a real fixed-timestep accumulator behind it would be
/// actively misleading, not just incomplete.
/// M4 adds the fixed-step accumulator this doc comment used to say was
/// missing: engine.physics needs to step Box3D at a constant rate
/// regardless of how the real frame rate wobbles, and a "FixedUpdate"
/// stage without a real accumulator behind it would be actively
/// misleading, not just incomplete. See Stage.FixedUpdate and
/// Time.ConsumeFixedSteps for the actual mechanism.
/// </summary>
public interface ITime
{
@@ -20,4 +19,12 @@ public interface ITime
double ElapsedTime { get; }
int FrameCount { get; }
/// <summary>
/// The constant step size Stage.FixedUpdate always runs at, regardless
/// of the real frame rate — 1/50s. Not per-project configurable yet:
/// nothing has needed a different rate, and a setting nobody's asked
/// for is speculative the same way an unconsumed accumulator was.
/// </summary>
float FixedDeltaTime { get; }
}
+35 -3
View File
@@ -2,15 +2,26 @@ namespace Engine.Kernel.Diagnostics;
/// <summary>
/// Read-only to plugins (ITime); advanced by whoever owns the frame loop —
/// Engine.Host today, headless or windowed — via <see cref="Tick"/>, which
/// isn't on the interface. Same split as Schedule/ISchedule: the host gets
/// the extra, plugin-facing code doesn't.
/// Engine.Host today, headless or windowed — via <see cref="Tick"/> and
/// <see cref="ConsumeFixedSteps"/>, neither on the interface. Same split as
/// Schedule/ISchedule: the host gets the extra, plugin-facing code doesn't.
/// </summary>
public sealed class Time : ITime
{
// Capped so a real stall — a debugger pause, a slow disk load — can't
// turn into a catch-up burst of hundreds of physics steps executing
// back to back, each one still assuming a normal frame budget around
// it. Five steps behind (0.1s at the default rate) already means
// something else is wrong; better to visibly fall behind real time
// than to freeze the process trying to simulate through it.
private const int MaxStepsPerFrame = 5;
private float _accumulator;
public float DeltaTime { get; private set; }
public double ElapsedTime { get; private set; }
public int FrameCount { get; private set; }
public float FixedDeltaTime { get; } = 1f / 50f;
public void Tick(float deltaTime)
{
@@ -18,4 +29,25 @@ public sealed class Time : ITime
ElapsedTime += deltaTime;
FrameCount++;
}
/// <summary>
/// Adds this frame's DeltaTime to the accumulator and returns how many
/// FixedDeltaTime-sized steps it can pay for, having already deducted
/// that much. Call once per frame, then run Stage.FixedUpdate that many
/// times (zero is normal — most frames are shorter than FixedDeltaTime
/// at 50Hz against a 60+fps display).
/// </summary>
public int ConsumeFixedSteps()
{
_accumulator += DeltaTime;
var steps = 0;
while (_accumulator >= FixedDeltaTime && steps < MaxStepsPerFrame)
{
_accumulator -= FixedDeltaTime;
steps++;
}
return steps;
}
}
+38 -1
View File
@@ -73,7 +73,34 @@ public sealed class PluginHost(
events.RegisterPlugin(manifest.Id, implAssembly);
var ctx = new PluginContext(manifest.Id, world, services, schedule, events, time);
instance.Configure(ctx);
try
{
instance.Configure(ctx);
}
catch
{
// Configure can fail after already doing real work — Schedule.
// Add, Events.Subscribe, Services.Provide calls all happen
// before whatever line actually throws. None of that unwinds
// on its own, and because _loaded never gets an entry for this
// id below, the plugin ends up neither loaded nor unloadable:
// its ALC stays rooted forever and any systems/subscriptions it
// did register before throwing keep running. Best-effort
// Shutdown first — it's the only thing that knows which
// services this specific plugin provided, so it's the only way
// to Revoke them — then the two RemoveAllFrom calls PluginHost
// itself can make unconditionally, then unload the ALC. Each
// step is wrapped so a broken Shutdown/unload can't hide the
// real Configure failure being rethrown below.
try { instance.Shutdown(ctx); } catch { /* best-effort */ }
schedule.RemoveAllFrom(manifest.Id);
events.RemoveAllFrom(manifest.Id);
try { alc.Unload(); } catch { /* best-effort */ }
throw;
}
_loaded[manifest.Id] = new LoadedPlugin(manifest, alc, instance, ctx);
events.Publish(new PluginLoaded(manifest.Id));
@@ -112,6 +139,16 @@ public sealed class PluginHost(
return loadedIds;
}
/// <summary>
/// Lets a caller check before calling Unload/Load instead of catching
/// InvalidOperationException to find out — Engine.Host's live-reload
/// command needs this: a plugin whose previous reload attempt failed
/// partway through Load isn't loaded any more (see Load's own rollback
/// on a Configure failure), so unconditionally trying Unload first
/// would itself throw "not loaded" on every retry.
/// </summary>
public bool IsLoaded(string pluginId) => _loaded.ContainsKey(pluginId);
/// <summary>
/// Runs Shutdown(), then unloads the plugin's ALC. Returns a weak
/// reference to the ALC so a caller can verify it actually collected —
+13 -7
View File
@@ -3,16 +3,22 @@ namespace Engine.Kernel.Scheduling;
/// <summary>
/// Fixed, kernel-defined, not plugin-extensible — see "Frame stages" in
/// docs/kernel-contract.md §8. <see cref="Present"/> is the second addition
/// to the original {Update, Render} set (after FixedUpdate was scoped for
/// M4), added for M3's editor: engine.render splits its old single
/// Clear+Draw+SwapBuffers system into a Render-stage draw and a
/// Present-stage swap specifically so engine.editor's ImGui overlay — which
/// has to run after the scene is drawn but before the buffers swap — has
/// somewhere to register that isn't a race against load order. Not a stage
/// added speculatively: SwapBuffers already needed to move somewhere real.
/// to the original {Update, Render} set, added for M3's editor: engine.
/// render splits its old single Clear+Draw+SwapBuffers system into a
/// Render-stage draw and a Present-stage swap specifically so engine.
/// editor's ImGui overlay — which has to run after the scene is drawn but
/// before the buffers swap — has somewhere to register that isn't a race
/// against load order. <see cref="FixedUpdate"/> is the third, arriving
/// with M4 exactly as ITime's own doc comment said it would: engine.physics
/// steps Box3D here, at ITime.FixedDeltaTime's fixed rate, run zero-or-more
/// times per frame by the accumulator Engine.Host drives — see
/// Time.ConsumeFixedSteps. Runs before Update each frame it fires, so
/// gameplay code reading a Rigidbody's position in Update sees this frame's
/// physics result, not last frame's.
/// </summary>
public enum Stage
{
FixedUpdate,
Update,
Render,
Present,
@@ -27,6 +27,29 @@ internal static class SystemAccessScope
return new Restore(previous);
}
/// <summary>
/// Clears any active scope for the duration of a bulk, whole-world
/// operation — GameWorld.Restore is the one real caller. Restore
/// rebuilds arbitrary component types from a snapshot via
/// GameObject.AddComponent, and it can be invoked from inside a
/// running system (a Play/Stop button click handled during Stage.
/// Render, say) as easily as from unconstrained editor/host code. This
/// class's own doc comment already says editor code and scene
/// construction are unconstrained by design; without this, that's only
/// true when the call happens to originate outside any system's own
/// scope, not because Restore is actually exempt — a real bug, not a
/// hypothetical one: entering then exiting Play mode from the editor's
/// own Stop button throws, because ExitPlay runs inside EditorPlugin's
/// Stage.Render system, which (correctly) never declares Writes&lt;T&gt;()
/// for component types it has no compile-time knowledge of.
/// </summary>
public static IDisposable Suspend()
{
var previous = Current.Value;
Current.Value = null;
return new Restore(previous);
}
/// <summary>Querying or fetching a component counts as a read — either
/// Reads&lt;T&gt;() or Writes&lt;T&gt;() satisfies it.</summary>
public static void CheckRead(Type componentType)
+6
View File
@@ -113,6 +113,12 @@ public sealed class GameWorld : IWorld
public void Restore(string snapshot)
{
// Suspended for the whole rebuild, not just around AddComponent:
// Restore is a bulk, whole-world reset regardless of which system
// (if any) happened to call it — see SystemAccessScope.Suspend's
// own doc comment for why this is a real, not hypothetical, fix.
using var _ = SystemAccessScope.Suspend();
foreach (var root in Roots.ToArray())
Destroy(root);
+120
View File
@@ -0,0 +1,120 @@
using Engine.Audio;
using Engine.Audio.Contracts;
using Engine.Kernel.Diagnostics;
using Engine.Kernel.World;
namespace Engine.Audio.Tests;
file sealed class RecordingLogger : ILogger
{
public List<string> Warnings { get; } = [];
public List<string> Errors { get; } = [];
public void Info(string message) { }
public void Warn(string message) => Warnings.Add(message);
public void Error(string message) => Errors.Add(message);
}
public class AudioWorldTests
{
private const string TestClip = "assets/test_tone.wav";
// Always the null backend: real playback needs a real audio device,
// which a test run shouldn't depend on or be heard making noise from.
// See AudioWorld's own doc comment.
private static AudioWorld NewWorld(ILogger log) => new(log, useNullBackend: true);
[Fact]
public void PlayOnAwake_StartsPlaybackAsSoonAsSourceIsSynced()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var audio = NewWorld(log);
var go = world.CreateGameObject("Sfx");
go.AddComponent<AudioSource>().ClipPath = TestClip;
go.GetComponent<AudioSource>()!.PlayOnAwake = true;
audio.Sync(world);
Assert.True(audio.IsPlaying(go));
Assert.Empty(log.Errors);
}
[Fact]
public void WithoutPlayOnAwake_DoesNotAutoPlay_ButServicePlayStarts()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var audio = NewWorld(log);
var go = world.CreateGameObject("Sfx");
go.AddComponent<AudioSource>().ClipPath = TestClip;
audio.Sync(world);
Assert.False(audio.IsPlaying(go));
audio.Play(go);
Assert.True(audio.IsPlaying(go));
}
[Fact]
public void Stop_StopsPlayback()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var audio = NewWorld(log);
var go = world.CreateGameObject("Sfx");
go.AddComponent<AudioSource>().ClipPath = TestClip;
audio.Sync(world);
audio.Play(go);
Assert.True(audio.IsPlaying(go));
audio.Stop(go);
Assert.False(audio.IsPlaying(go));
}
[Fact]
public void MissingClip_WarnsAndDoesNotThrow()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var audio = NewWorld(log);
var go = world.CreateGameObject("Broken");
go.AddComponent<AudioSource>().ClipPath = "assets/does_not_exist.wav";
audio.Sync(world);
audio.Sync(world); // a second sync shouldn't re-warn or throw either
Assert.Single(log.Warnings);
Assert.False(audio.IsPlaying(go));
}
[Fact]
public void DestroyedGameObject_SoundCleanedUp_SubsequentSyncsStillWork()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var audio = NewWorld(log);
var go = world.CreateGameObject("Temp");
go.AddComponent<AudioSource>().ClipPath = TestClip;
audio.Sync(world);
audio.Play(go);
world.Destroy(go);
audio.Sync(world); // should unload the now-orphaned sound, not throw
// A second GameObject reusing the same clip proves the world is
// still in a usable state after the cleanup above.
var go2 = world.CreateGameObject("Temp2");
go2.AddComponent<AudioSource>().ClipPath = TestClip;
audio.Sync(world);
audio.Play(go2);
Assert.True(audio.IsPlaying(go2));
}
}
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Linux'))">
<Content Include="../../plugins/engine.audio/Engine.Audio/native/linux-x64/liblingua_audio.so">
<Link>liblingua_audio.so</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
<Content Include="../../plugins/engine.audio/Engine.Audio/native/win-x64/lingua_audio.dll">
<Link>lingua_audio.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Content Include="assets/test_tone.wav">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\plugins\engine.audio\Engine.Audio\Engine.Audio.csproj" />
</ItemGroup>
</Project>
Binary file not shown.
@@ -32,6 +32,11 @@
the harness's Default ALC, which defeats the point of a leak test. -->
<ProjectReference Include="..\..\plugins\sandbox.echo\Sandbox.Echo\Sandbox.Echo.csproj"
ReferenceOutputAssembly="false" />
<!-- Same build-order-only reasoning, for PluginHostTests' Configure-
throws-partway-through fixture. -->
<ProjectReference Include="..\..\plugins\sandbox.failing-configure\Sandbox.FailingConfigure\Sandbox.FailingConfigure.csproj"
ReferenceOutputAssembly="false" />
</ItemGroup>
<!-- Flatten plugin.json + both built DLLs into one directory under this
@@ -54,6 +59,15 @@
<Link>plugins\sandbox.echo\Sandbox.Echo.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\plugins\sandbox.failing-configure\plugin.json">
<Link>plugins\sandbox.failing-configure\plugin.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\plugins\sandbox.failing-configure\Sandbox.FailingConfigure\bin\$(Configuration)\$(TargetFramework)\Sandbox.FailingConfigure.dll">
<Link>plugins\sandbox.failing-configure\Sandbox.FailingConfigure.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,100 @@
using Engine.Kernel.Diagnostics;
using Engine.Kernel.Events;
using Engine.Kernel.Plugins;
using Engine.Kernel.Scheduling;
using Engine.Kernel.Services;
using Engine.Kernel.World;
using Sandbox.Echo.Contracts;
namespace Engine.ConformanceHarness;
/// <summary>
/// Found by independent review, not by any test: PluginHost.Load didn't
/// roll back anything when Configure threw partway through — Schedule.Add/
/// Events.Subscribe calls it already made stayed registered forever, and
/// the ALC it loaded into was never unloaded. Sandbox.FailingConfigure
/// exists specifically to load real, into a real collectible ALC, and
/// throw after registering a system, exactly reproducing that shape.
///
/// The system it registers increments a shared Ping component — a real,
/// deterministic side effect. An earlier version of this test tried to
/// prove the rollback via AssemblyLoadContext.All instead (did the failed
/// load's ALC get collected?), but that passed even against the
/// deliberately-reverted buggy PluginHost.Load: the ALC turned out to get
/// collected either way once its only references (local variables inside
/// Load) went out of scope, regardless of whether Schedule/EventBus still
/// held onto its assembly. Watching whether the dangling system still
/// fires is the thing that actually distinguishes rolled-back from not.
/// </summary>
public class FailedConfigureRollbackTests
{
private static string PluginDirectory =>
Path.Combine(AppContext.BaseDirectory, "plugins", "sandbox.failing-configure");
private static PluginHost NewHost(Schedule schedule, GameWorld world) =>
new(world, new ServiceRegistry(), schedule, new EventBus(), new Time());
[Fact]
public void Load_Rethrows_The_Configure_Exception()
{
var host = NewHost(new Schedule(), new GameWorld());
var ex = Assert.Throws<InvalidOperationException>(() => host.Load(PluginDirectory));
Assert.Equal("deliberate failure for PluginHostTests", ex.Message);
}
[Fact]
public void Load_Fails_The_Same_Way_On_Retry_Instead_Of_Already_Loaded()
{
var host = NewHost(new Schedule(), new GameWorld());
Assert.Throws<InvalidOperationException>(() => host.Load(PluginDirectory));
// Before the fix, _loaded never got an entry either way — this
// alone wouldn't have caught the bug — but it's still the right
// thing to be true: retrying isn't "already loaded," it fails the
// same way every time.
var ex = Assert.Throws<InvalidOperationException>(() => host.Load(PluginDirectory));
Assert.Equal("deliberate failure for PluginHostTests", ex.Message);
}
[Fact]
public void IsLoaded_Is_False_After_A_Failed_Load_So_Retry_Does_Not_Need_Unload_First()
{
var host = NewHost(new Schedule(), new GameWorld());
Assert.Throws<InvalidOperationException>(() => host.Load(PluginDirectory));
Assert.False(host.IsLoaded("sandbox.failing-configure"));
// Mirrors Engine.Host's own "r <id>" handler: only Unload first if
// IsLoaded says so. Before that check existed, this exact retry
// sequence threw "Plugin 'sandbox.failing-configure' is not
// loaded" instead of ever reaching Load again.
if (host.IsLoaded("sandbox.failing-configure"))
host.Unload("sandbox.failing-configure");
Assert.Throws<InvalidOperationException>(() => host.Load(PluginDirectory));
}
[Fact]
public void Load_Removes_The_System_Configure_Registered_Before_Throwing()
{
var world = new GameWorld();
var schedule = new Schedule();
var host = NewHost(schedule, world);
var ping = world.CreateGameObject("Pinger").AddComponent<Ping>();
Assert.Throws<InvalidOperationException>(() => host.Load(PluginDirectory));
// Without rollback, the Tick system FailingConfigurePlugin
// registered before throwing is still sitting in Schedule and
// fires here, incrementing Count. With rollback (schedule.
// RemoveAllFrom in the catch block), it's gone, and this does
// nothing.
schedule.RunStage(Stage.Update, world);
schedule.RunStage(Stage.Update, world);
Assert.Equal(0, ping.Count);
}
}
+33
View File
@@ -38,4 +38,37 @@ public class TimeTests
Assert.Equal(0.75, time.ElapsedTime, 3);
Assert.Equal(2, time.FrameCount);
}
[Fact]
public void ConsumeFixedSteps_BelowOneStep_ReturnsZero()
{
var time = new Time();
// FixedDeltaTime is 1/50 = 0.02s; a 60fps-ish frame is shorter.
time.Tick(0.01f);
Assert.Equal(0, time.ConsumeFixedSteps());
}
[Fact]
public void ConsumeFixedSteps_ConsumesWholeStepsAndKeepsRemainder()
{
var time = new Time();
time.Tick(0.05f); // 2.5 steps' worth
Assert.Equal(2, time.ConsumeFixedSteps());
time.Tick(0.03f); // remainder 0.01 + 0.03 = 0.04 -> 2 more steps
Assert.Equal(2, time.ConsumeFixedSteps());
}
[Fact]
public void ConsumeFixedSteps_CapsAtFiveEvenAfterAHugeStall()
{
var time = new Time();
time.Tick(10f); // a debugger pause, not a real frame
Assert.Equal(5, time.ConsumeFixedSteps());
}
}
@@ -1,4 +1,5 @@
using System.Diagnostics;
using Engine.Kernel.Scheduling;
using Engine.Kernel.World;
namespace Engine.Kernel.Tests;
@@ -103,4 +104,30 @@ public class WorldSnapshotTests
stopwatch.ElapsedMilliseconds < 100,
$"Snapshot + Restore of 300 GameObjects took {stopwatch.ElapsedMilliseconds} ms, expected < 100 ms.");
}
// A real bug, caught by hand: entering then exiting Play mode via
// engine.editor's own Stop button threw InvalidOperationException,
// because ExitPlay runs Restore from inside EditorPlugin's Stage.
// Render system — which correctly declares no Reads/Writes at all, it
// has no compile-time knowledge of QuadRenderer or any other game's
// component types — and Restore rebuilds those very types via
// AddComponent. Every other test in this file calls Restore directly,
// outside any system's scope, which is exactly why none of them caught
// this. This one reproduces the real call shape: a system with no
// declared access, calling Restore, run through the real Schedule.
[Fact]
public void Restore_Called_From_A_System_With_No_Declared_Access_Does_Not_Throw()
{
var world = new GameWorld();
var go = world.CreateGameObject("Hero");
go.AddComponent<Health>().Value = 100;
var snapshot = world.Snapshot();
var schedule = new Schedule();
schedule.Add(Stage.Update, (IWorld w) => w.Restore(snapshot)); // no Reads/Writes — like EditorPlugin's DrawUi
var exception = Record.Exception(() => schedule.RunStage(Stage.Update, world));
Assert.Null(exception);
}
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<!-- Same native shim Engine.Physics itself ships, same per-OS split —
see that project's own Content items. -->
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Linux'))">
<Content Include="../../plugins/engine.physics/Engine.Physics/native/linux-x64/liblingua_physics.so">
<Link>liblingua_physics.so</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
<Content Include="../../plugins/engine.physics/Engine.Physics/native/win-x64/lingua_physics.dll">
<Link>lingua_physics.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\plugins\engine.physics\Engine.Physics\Engine.Physics.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,219 @@
using System.Numerics;
using Engine.Kernel.Diagnostics;
using Engine.Kernel.World;
using Engine.Physics;
using Engine.Physics.Contracts;
namespace Engine.Physics.Tests;
file sealed class RecordingLogger : ILogger
{
public List<string> Warnings { get; } = [];
public void Info(string message) { }
public void Warn(string message) => Warnings.Add(message);
public void Error(string message) { }
}
public class PhysicsWorldTests
{
private static GameObject CreateGround(GameWorld world)
{
var go = world.CreateGameObject("Ground");
go.Transform = Transform.Identity;
go.AddComponent<Rigidbody>().Type = BodyType.Static;
go.AddComponent<BoxCollider>().HalfExtents = new Vector3(10f, 0.5f, 10f);
return go;
}
[Fact]
public void DynamicBox_FallsAndSettlesOnStaticGround()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
CreateGround(world);
var box = world.CreateGameObject("FallingBox");
box.Transform = Transform.Identity;
box.Transform.LocalPosition = new Vector3(0, 5, 0);
box.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
box.AddComponent<BoxCollider>().HalfExtents = new Vector3(0.5f, 0.5f, 0.5f);
for (var i = 0; i < 150; i++)
{
physics.Sync(world);
physics.Step(1f / 50f);
}
// Ground half-height 0.5 + box half-height 0.5 = rests at y=1.
Assert.Equal(1f, box.Transform.LocalPosition.Y, 1);
Assert.Empty(log.Warnings);
}
[Fact]
public void DynamicSphere_FallsAndSettlesOnStaticGround()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
CreateGround(world);
var sphere = world.CreateGameObject("FallingSphere");
sphere.Transform = Transform.Identity;
sphere.Transform.LocalPosition = new Vector3(0, 5, 0);
sphere.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
sphere.AddComponent<SphereCollider>().Radius = 0.5f;
for (var i = 0; i < 150; i++)
{
physics.Sync(world);
physics.Step(1f / 50f);
}
Assert.Equal(1f, sphere.Transform.LocalPosition.Y, 1);
}
[Fact]
public void RigidbodyWithoutCollider_WarnsOnceAndDoesNotThrow()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
var go = world.CreateGameObject("NoShape");
go.Transform = Transform.Identity;
go.AddComponent<Rigidbody>();
for (var i = 0; i < 5; i++)
{
physics.Sync(world);
physics.Step(1f / 50f);
}
Assert.Single(log.Warnings);
}
[Fact]
public void DestroyedGameObject_BodyCleanedUp_SubsequentStepsStillWork()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
CreateGround(world);
var box = world.CreateGameObject("Temp");
box.Transform = Transform.Identity;
box.Transform.LocalPosition = new Vector3(0, 5, 0);
box.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
box.AddComponent<BoxCollider>();
physics.Sync(world);
physics.Step(1f / 50f);
world.Destroy(box);
// Should not throw even though the body backing a now-destroyed
// GameObject still existed in the tracking table until this Sync.
for (var i = 0; i < 10; i++)
{
physics.Sync(world);
physics.Step(1f / 50f);
}
}
[Fact]
public void ApplyLinearImpulse_ChangesVelocity()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var physics = new PhysicsWorld(Vector3.Zero, log); // no gravity, isolate the impulse
var box = world.CreateGameObject("Box");
box.Transform = Transform.Identity;
box.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
box.AddComponent<BoxCollider>();
physics.Sync(world); // create the body before applying an impulse to it
physics.ApplyLinearImpulse(box, new Vector3(5, 0, 0), wake: true);
var velocity = physics.GetLinearVelocity(box);
Assert.True(velocity.X > 0f, $"expected positive X velocity after impulse, got {velocity.X}");
}
// Sync's old early-return compared _bodies.Count to live.Count, not
// their contents — a real leak found by independent review, not by
// any of the tests above (all of them either never remove a
// GameObject, or do so in a way that changes the count). Both tests
// below reproduce the review's own two scenarios and assert on
// Native.Lingua_GetBodyCount() directly: the native table's own count
// is what actually leaks, and PhysicsWorld's C#-side _bodies
// dictionary alone can't prove it didn't.
[Fact]
public void Sync_DestroysStaleBody_EvenWhenLiveCountStaysTheSame()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
var a = world.CreateGameObject("A");
a.Transform = Transform.Identity;
a.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
a.AddComponent<BoxCollider>();
// B has a Rigidbody but no collider — it counts toward "live"
// every Sync (any Rigidbody does) but never gets a native body.
var b = world.CreateGameObject("B");
b.Transform = Transform.Identity;
b.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
physics.Sync(world);
Assert.Equal(1, Native.Lingua_GetBodyCount());
world.Destroy(a);
// live = {B} (1), stale _bodies = {A} (1) — same count as before
// destroying A, which is exactly what let the old bug's early
// return skip cleanup.
physics.Sync(world);
Assert.Equal(0, Native.Lingua_GetBodyCount());
}
[Fact]
public void Sync_DestroysStaleBody_AcrossARestore()
{
var world = new GameWorld();
var log = new RecordingLogger();
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
var a = world.CreateGameObject("A");
a.Transform = Transform.Identity;
a.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
a.AddComponent<BoxCollider>();
var b = world.CreateGameObject("B"); // Rigidbody, no collider
b.Transform = Transform.Identity;
b.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
physics.Sync(world);
Assert.Equal(1, Native.Lingua_GetBodyCount());
// Restore destroys A and B and rebuilds fresh instances A'/B' from
// the snapshot. The next Sync creates A' (a new body) while old
// A's body is now orphaned — _bodies briefly holds {A, A'} (2)
// and live holds {A', B'} (2), the same count, which is exactly
// what the old bug's early return let slip through as "nothing
// stale to clean up."
var snapshot = world.Snapshot();
world.Restore(snapshot);
physics.Sync(world);
// Exactly one live native body (A'), not two (leaked old A + A').
Assert.Equal(1, Native.Lingua_GetBodyCount());
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../samples/PhysicsDemo/GamePlugins/physics-demo-game/PhysicsDemoGame.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,176 @@
using System.Numerics;
using Engine.Audio.Contracts;
using Engine.Input.Contracts;
using Engine.Kernel.Diagnostics;
using Engine.Kernel.Events;
using Engine.Kernel.Plugins;
using Engine.Kernel.Scheduling;
using Engine.Kernel.Services;
using Engine.Kernel.World;
using Engine.Physics.Contracts;
using PhysicsDemoGame;
using Silk.NET.Input;
namespace PhysicsDemoGame.Tests;
// No real window, no real GLFW keyboard, no real Box3D/miniaudio needed to
// test this plugin's OWN logic (spawn-on-press, landed-detection) — only
// whether it calls its three service dependencies correctly. Real OS-level
// key injection isn't available in this sandbox (no xdotool/ydotool under
// Wayland) — a fake IEngineInput the test drives directly is the honest
// substitute, not a compromise: it exercises PhysicsDemoGamePlugin's real
// Configure()/Tick() through the real Schedule (so SystemAccessScope's
// Writes<Rigidbody/BoxCollider/CubeRenderer>() declarations are genuinely
// checked), with IPhysicsService/IAudioService swapped for controllable
// fakes since engine.physics/engine.audio's own correctness is already
// covered by their own test projects.
internal sealed class FakeInput : IEngineInput
{
public bool SpaceDown;
public bool IsKeyDown(Key key) => key == Key.Space && SpaceDown;
public IInputContext Native => throw new NotSupportedException();
}
internal sealed class FakePhysics : IPhysicsService
{
public readonly Dictionary<GameObject, Vector3> Velocity = [];
public void ApplyLinearImpulse(GameObject go, Vector3 impulse, bool wake = true) { }
public Vector3 GetLinearVelocity(GameObject go) => Velocity.GetValueOrDefault(go, Vector3.Zero);
public void SetLinearVelocity(GameObject go, Vector3 velocity) => Velocity[go] = velocity;
}
internal sealed class FakeAudio : IAudioService
{
public readonly List<GameObject> Played = [];
public void Play(GameObject go) => Played.Add(go);
public void Stop(GameObject go) { }
public bool IsPlaying(GameObject go) => false;
}
internal sealed class NullLogger : ILogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message) { }
}
internal sealed class TestPluginContext(
IWorld world, IServiceRegistry services, ISchedule schedule, IEventBus events, ITime time) : IPluginContext
{
public IWorld World { get; } = world;
public IServiceRegistry Services { get; } = services;
public ISchedule Schedule { get; } = schedule;
public IEventBus Events { get; } = events;
public ILogger Log { get; } = new NullLogger();
public ITime Time { get; } = time;
}
public class PhysicsDemoGamePluginTests
{
private static (GameWorld World, Schedule Schedule, FakeInput Input, FakePhysics Physics, FakeAudio Audio) Setup()
{
var world = new GameWorld();
var schedule = new Schedule();
var services = new ServiceRegistry();
var input = new FakeInput();
var physics = new FakePhysics();
var audio = new FakeAudio();
services.Provide<IEngineInput>(input);
services.Provide<IPhysicsService>(physics);
services.Provide<IAudioService>(audio);
var ctx = new TestPluginContext(world, services, schedule, new EventBus(), new Time());
new global::PhysicsDemoGame.PhysicsDemoGamePlugin().Configure(ctx);
return (world, schedule, input, physics, audio);
}
[Fact]
public void PressingSpace_SpawnsExactlyOneBox_EvenIfHeldAcrossFrames()
{
var (world, schedule, input, _, _) = Setup();
input.SpaceDown = true;
schedule.RunStage(Stage.Update, world); // press
schedule.RunStage(Stage.Update, world); // still held — no second spawn
Assert.Single(world.Roots);
}
[Fact]
public void ReleasingAndPressingAgain_SpawnsASecondBox()
{
var (world, schedule, input, _, _) = Setup();
input.SpaceDown = true;
schedule.RunStage(Stage.Update, world);
input.SpaceDown = false;
schedule.RunStage(Stage.Update, world);
input.SpaceDown = true;
schedule.RunStage(Stage.Update, world);
Assert.Equal(2, world.Roots.Count);
}
[Fact]
public void SpawnedBox_HasRigidbodyBoxColliderAndCubeRenderer()
{
var (world, schedule, input, _, _) = Setup();
input.SpaceDown = true;
schedule.RunStage(Stage.Update, world);
var box = Assert.Single(world.Roots);
Assert.NotNull(box.GetComponent<Rigidbody>());
Assert.NotNull(box.GetComponent<BoxCollider>());
Assert.Equal(BodyType.Dynamic, box.GetComponent<Rigidbody>()!.Type);
}
[Fact]
public void BoxThatNeverActuallyFalls_DoesNotTriggerTheBounceSound()
{
var (world, schedule, input, physics, audio) = Setup();
var bounce = world.CreateGameObject("Bounce");
input.SpaceDown = true;
schedule.RunStage(Stage.Update, world);
var box = world.Roots.First(go => go != bounce);
// Spawned at rest (velocity never set below the falling threshold)
// — must not be mistaken for "landed."
for (var i = 0; i < 5; i++)
schedule.RunStage(Stage.Update, world);
Assert.Empty(audio.Played);
}
[Fact]
public void BoxThatFallsThenSettles_TriggersTheBounceSoundExactlyOnce()
{
var (world, schedule, input, physics, audio) = Setup();
var bounce = world.CreateGameObject("Bounce");
input.SpaceDown = true;
schedule.RunStage(Stage.Update, world);
input.SpaceDown = false;
var box = world.Roots.First(go => go != bounce);
physics.Velocity[box] = new Vector3(0, -5, 0); // falling fast
schedule.RunStage(Stage.Update, world);
Assert.Empty(audio.Played);
physics.Velocity[box] = new Vector3(0, 0, 0); // settled
schedule.RunStage(Stage.Update, world);
Assert.Single(audio.Played);
Assert.Same(bounce, audio.Played[0]);
// Still resting on later frames — must not re-trigger.
schedule.RunStage(Stage.Update, world);
schedule.RunStage(Stage.Update, world);
Assert.Single(audio.Played);
}
}