diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 015c5c3b..39acb9d9 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -610,7 +610,15 @@ public sealed class GameWindow : IDisposable
// transition link (see PhysicsBody.InWorld).
InWorld = true,
};
- Motion = new AcDream.Core.Physics.MotionInterpreter(Body);
+ Motion = new AcDream.Core.Physics.MotionInterpreter(Body)
+ {
+ // R4-V5 #160 fix: retail remotes carry a weenie whose
+ // InqRunRate FAILS, landing apply_run_to_command on
+ // my_run_rate (the M13 wire feed). A null weenie took the
+ // degenerate 1.0 branch — observer-side run movetos played
+ // and moved in slow motion. See RemoteWeenie.
+ WeenieObj = new AcDream.Core.Physics.RemoteWeenie(),
+ };
}
}
diff --git a/src/AcDream.Core/Physics/RemoteWeenie.cs b/src/AcDream.Core/Physics/RemoteWeenie.cs
new file mode 100644
index 00000000..6d6aedb9
--- /dev/null
+++ b/src/AcDream.Core/Physics/RemoteWeenie.cs
@@ -0,0 +1,54 @@
+namespace AcDream.Core.Physics;
+
+///
+/// R4-V5 (#160 fix, 2026-07-03): the minimal weenie every REMOTE entity's
+/// carries — standing in for retail's
+/// per-object ACCWeenieObject, which every placed physics object has.
+///
+///
+/// The load-bearing behavior is the RUN-RATE FALLBACK CHAIN:
+/// apply_run_to_command (0x00527be0, raw 305062-305076) reads
+/// weenie ? (InqRunRate() ?: my_run_rate) : 1.0. Remote weenies in
+/// retail FAIL InqRunRate (no local skill data), landing on
+/// my_run_rate — exactly the field the mt-6/7 unpack writes with the
+/// wire's MoveToRunRate (M13). Our remote interps previously had NO
+/// weenie at all, taking the degenerate else 1.0 branch retail
+/// reserves for detached objects — so an observer-side moveto dispatched
+/// RunForward at speed 1.0 instead of the wire rate: the run cycle played
+/// in slow motion and the chase velocity crawled (walk was unaffected —
+/// walk speed isn't rate-scaled).
+///
+///
+///
+/// stays default false — the A3
+/// dual dispatch routes remotes through apply_interpreted_movement, ending
+/// the fragile "null weenie counts as the player" reading of that gate.
+/// stays default true; static
+/// animated objects (doors) also carry this weenie — retail would say
+/// non-creature there, but their RemoteMotion bodies force-assert Contact,
+/// so the creature ground-gate is satisfied either way (noted, not a
+/// behavior difference today).
+///
+///
+public sealed class RemoteWeenie : IWeenieObject
+{
+ /// Remote objects have no local skill data — fail, so the
+ /// retail fallback chain lands on
+ /// (the wire-fed rate).
+ public bool InqRunRate(out float rate)
+ {
+ rate = 0f;
+ return false;
+ }
+
+ /// Remotes never locally compute jump arcs (server-broadcast
+ /// VectorUpdate velocities drive them).
+ public bool InqJumpVelocity(float extent, out float vz)
+ {
+ vz = 0f;
+ return false;
+ }
+
+ /// Remotes never locally initiate jumps.
+ public bool CanJump(float extent) => true;
+}
diff --git a/tests/AcDream.Core.Tests/Physics/RemoteWeenieRunRateTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteWeenieRunRateTests.cs
new file mode 100644
index 00000000..e54d8aa3
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/RemoteWeenieRunRateTests.cs
@@ -0,0 +1,52 @@
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// R4-V5 #160 fix: retail's apply_run_to_command (0x00527be0) run-rate
+/// chain is weenie ? (InqRunRate() ?: my_run_rate) : 1.0. Remote
+/// weenies FAIL InqRunRate, landing on my_run_rate — the field the mt-6/7
+/// unpack writes with the wire's MoveToRunRate (M13). These pin that a
+/// RemoteWeenie-equipped interp scales the run promotion by MyRunRate
+/// (observer-side movetos ran in slow motion when the interp had no
+/// weenie and took the degenerate 1.0 branch).
+///
+public class RemoteWeenieRunRateTests
+{
+ [Fact]
+ public void ApplyRunToCommand_RemoteWeenie_ScalesByWireRunRate()
+ {
+ var interp = new MotionInterpreter(new PhysicsBody())
+ {
+ WeenieObj = new RemoteWeenie(),
+ MyRunRate = 4.5f, // the mt-6 wire write (unpack @300603)
+ };
+
+ uint motion = MotionCommand.WalkForward;
+ float speed = 1.0f;
+ interp.apply_run_to_command(ref motion, ref speed);
+
+ Assert.Equal(MotionCommand.RunForward, motion);
+ Assert.Equal(4.5f, speed, 3);
+ }
+
+ [Fact]
+ public void ApplyRunToCommand_NoWeenie_KeepsRetailDegenerateBranch()
+ {
+ // Verbatim retail: a weenie-LESS (detached-class) object scales by
+ // 1.0 (raw 305062-305076 `else x87_r7 = 1f`). Production bodies all
+ // carry a weenie (player: PlayerWeenie; remotes: RemoteWeenie).
+ var interp = new MotionInterpreter(new PhysicsBody())
+ {
+ MyRunRate = 4.5f,
+ };
+
+ uint motion = MotionCommand.WalkForward;
+ float speed = 1.0f;
+ interp.apply_run_to_command(ref motion, ref speed);
+
+ Assert.Equal(MotionCommand.RunForward, motion);
+ Assert.Equal(1.0f, speed, 3);
+ }
+}