Run 170's Windows gate went red on 37 tests across four assemblies while the same commit passed 14,370/0 locally. The failures were all one family: Expected: "You have 1 500p" <- built with the machine's culture Actual: "You have 1,500p" <- production, correctly invariant The runner is Swedish; this dev box is not. These tests had been passing on CI only because that machine's registry locale had been pinned by hand — machine state, which came undone (almost certainly the reboot after today's hang). Re-pinning it would be a workaround on one machine for a defect in the repo, so this fixes the repo instead. Two genuinely different bugs were hiding in that one symptom. 1. TESTS that build an expected string with the ambient culture and compare it to invariant production output, and test-side recording sinks whose traces are compared against literal golden strings. Those only ever passed on a machine that happens to format like the invariant culture. Pinned to InvariantCulture: the vendor purse/cost expectations, and the motion-funnel, animation-sequencer, framebuffer-resize, resource-slot, and runtime-attack trace sinks. 2. PRODUCTION that formats player-visible retail text with the ambient culture. This one matters beyond CI: retail is a US client, so it shows "2.50", "1,500p" and "(-20)" to everyone. On a Swedish machine acdream was showing "2,50", "1 500p" and "(-20)" with U+2212 MINUS SIGN — the audience for this alpha is literally Swedish. Converted 76 sites to InvariantCulture across the item/creature appraisal formatters, the character stat panel's buff and vitae parentheticals, the appraisal and link-status controllers, the chat /framerate and /location output, the camera sensitivity toast, the time-override toast, the F3 dump, the sky diagnostics, and the world-frame invariant-failure message. DATES are deliberately left on the current culture (CharacterController's birth/login stamp, RuntimeHouseState's purchase expiry). Retail has no answer for a non-US player's date format, and forcing "08/19/2026 7:00:00 PM" on them is a UX decision, not a retail-fidelity one. Apparatus, so the next occurrence is reproducible instead of mysterious: tests/TestCultureInitializer.cs adds an opt-in ACDREAM_TEST_CULTURE knob to every test assembly, linked in through a new tests/Directory.Build.props. Unset — what CI and everyone runs — it changes nothing. ACDREAM_TEST_CULTURE=sv-SE dotnet test ... reproduced all 37 CI failures on this machine plus 6 more the runner's own locale does not surface (the Unicode-minus family), and drove the fix. Verified both ways on the full solution under the release-gate filter: default culture 14,370 passed / 0 failed, and ACDREAM_TEST_CULTURE=sv-SE 14,370 passed / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
383 lines
15 KiB
C#
383 lines
15 KiB
C#
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using AcDream.Core.Physics;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// L.2g S2 — the inbound CMotionInterp funnel (deviation DEV-1).
|
|
///
|
|
/// Oracle: docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md
|
|
/// (decomp: move_to_interpreted_state 0x005289c0 @305936,
|
|
/// apply_interpreted_movement 0x00528600 @305713) — validated against the
|
|
/// LIVE retail-observer cdb trace (l2g-observer-trace.log), which showed the
|
|
/// per-UM dispatch order verbatim: style → forward → sidestep(-stop) →
|
|
/// turn(-stop).
|
|
/// </summary>
|
|
public class MotionInterpreterFunnelTests
|
|
{
|
|
private sealed class RecordingSink : IInterpretedMotionSink
|
|
{
|
|
public readonly List<string> Calls = new();
|
|
public bool ApplyMotion(uint motion, float speed)
|
|
{
|
|
// Invariant: this trace is compared against literal golden
|
|
// strings, so it must not follow the machine's locale (sv-SE
|
|
// renders 1.00 as "1,00" and -1.20 with a Unicode minus).
|
|
Calls.Add(string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"DIM {motion:x8}@{speed:F2}"));
|
|
// R3-W5: a style/stance id (>= 0x80000000, i.e. negative as
|
|
// int32) has no locomotion MotionData entry in the dat — retail's
|
|
// real CMotionTable::DoObjectMotion genuinely fails for it
|
|
// (MotionTableManagerError.MotionFailed). The fake sink mirrors
|
|
// that so InterpretedMotionState.ForwardCommand isn't clobbered
|
|
// by ApplyMotion's negative-motion branch before the very next
|
|
// dispatch reads it — matches the live retail-observer trace.
|
|
return motion < 0x80000000u;
|
|
}
|
|
public bool StopMotion(uint motion)
|
|
{
|
|
Calls.Add($"STOP {motion:x8}");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private static MotionInterpreter GroundedInterp()
|
|
{
|
|
var body = new PhysicsBody();
|
|
body.State |= PhysicsStateFlags.Gravity;
|
|
body.TransientState |= TransientStateFlags.Contact
|
|
| TransientStateFlags.OnWalkable
|
|
| TransientStateFlags.Active;
|
|
return new MotionInterpreter(body);
|
|
}
|
|
|
|
private static InboundInterpretedState Ims(
|
|
uint style = 0x8000003Du,
|
|
uint fwd = 0x41000003u, float fwdSpd = 1.0f,
|
|
uint side = 0u, float sideSpd = 1.0f,
|
|
uint turn = 0u, float turnSpd = 1.0f,
|
|
IReadOnlyList<InboundMotionAction>? actions = null)
|
|
=> new()
|
|
{
|
|
CurrentStyle = style,
|
|
ForwardCommand = fwd, ForwardSpeed = fwdSpd,
|
|
SideStepCommand = side, SideStepSpeed = sideSpd,
|
|
TurnCommand = turn, TurnSpeed = turnSpd,
|
|
Actions = actions,
|
|
};
|
|
|
|
[Fact]
|
|
public void EmptyUm_DispatchesStyleThenReadyThenStops_RetailOrder()
|
|
{
|
|
// The flags=0 "empty" UM: all defaults → a wholesale stop. Live-trace
|
|
// golden (actor minterp 18e8b0f8): DIM style, DIM Ready, then the
|
|
// sidestep + turn stop notifications.
|
|
var mi = GroundedInterp();
|
|
var sink = new RecordingSink();
|
|
|
|
mi.MoveToInterpretedState(Ims(), sink);
|
|
|
|
Assert.Equal(new[]
|
|
{
|
|
"DIM 8000003d@1.00",
|
|
"DIM 41000003@1.00",
|
|
"STOP 6500000f",
|
|
"STOP 6500000d",
|
|
}, sink.Calls);
|
|
Assert.Equal(0x41000003u, mi.InterpretedState.ForwardCommand);
|
|
Assert.Equal(1.0f, mi.InterpretedState.ForwardSpeed);
|
|
}
|
|
|
|
[Fact]
|
|
public void RunUm_DispatchesRunAtWireSpeed_AndCachesMyRunRate()
|
|
{
|
|
// fwd=RunForward@2.85 — apply_interpreted_movement caches
|
|
// my_run_rate from forward_speed BEFORE dispatching (305718).
|
|
var mi = GroundedInterp();
|
|
var sink = new RecordingSink();
|
|
|
|
mi.MoveToInterpretedState(Ims(fwd: 0x44000007u, fwdSpd: 2.85f), sink);
|
|
|
|
Assert.Equal(new[]
|
|
{
|
|
"DIM 8000003d@1.00",
|
|
"DIM 44000007@2.85",
|
|
"STOP 6500000f",
|
|
"STOP 6500000d",
|
|
}, sink.Calls);
|
|
Assert.Equal(2.85f, mi.MyRunRate);
|
|
Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand);
|
|
Assert.Equal(2.85f, mi.InterpretedState.ForwardSpeed);
|
|
}
|
|
|
|
[Fact]
|
|
public void WalkUm_DoesNotTouchMyRunRate()
|
|
{
|
|
var mi = GroundedInterp();
|
|
mi.MyRunRate = 2.5f;
|
|
|
|
mi.MoveToInterpretedState(Ims(fwd: 0x45000005u), new RecordingSink());
|
|
|
|
Assert.Equal(2.5f, mi.MyRunRate); // only RunForward caches
|
|
}
|
|
|
|
[Fact]
|
|
public void RunPlusTurnUm_TurnDispatched_NoTurnStop_NoIdleEnqueue()
|
|
{
|
|
// turn_command != 0 → DIM(turn) then EARLY RETURN — no turn-stop,
|
|
// no idle bookkeeping (305711-305713 early return).
|
|
var mi = GroundedInterp();
|
|
var sink = new RecordingSink();
|
|
|
|
mi.MoveToInterpretedState(
|
|
Ims(fwd: 0x44000007u, fwdSpd: 2.85f, turn: 0x6500000Du, turnSpd: 1.5f), sink);
|
|
|
|
Assert.Equal(new[]
|
|
{
|
|
"DIM 8000003d@1.00",
|
|
"DIM 44000007@2.85",
|
|
"STOP 6500000f",
|
|
"DIM 6500000d@1.50",
|
|
}, sink.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void SidestepUm_DispatchedInsteadOfSidestepStop()
|
|
{
|
|
var mi = GroundedInterp();
|
|
var sink = new RecordingSink();
|
|
|
|
mi.MoveToInterpretedState(Ims(side: 0x6500000Fu, sideSpd: -1.2f), sink);
|
|
|
|
Assert.Equal(new[]
|
|
{
|
|
"DIM 8000003d@1.00",
|
|
"DIM 41000003@1.00",
|
|
"DIM 6500000f@-1.20",
|
|
"STOP 6500000d",
|
|
}, sink.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void AirborneBody_NoCycleDispatches_OnlyTurnStop()
|
|
{
|
|
// Airborne (gravity on, no Contact): apply_interpreted_movement
|
|
// substitutes DIM(Falling 0x40000015) for the forward block
|
|
// (305723-305727), but DoInterpretedMotion's OWN contact gate
|
|
// (0x00528360) then takes the apply-only path for style + Falling —
|
|
// GetObjectSequence never fires. This is retail's real mechanism
|
|
// behind the K-fix17 "preserve the Falling cycle while airborne"
|
|
// empirical guard: airborne remotes simply don't re-cycle from UMs.
|
|
// Only the unconditional turn-stop notification comes through.
|
|
var body = new PhysicsBody(); // no Contact flag
|
|
body.State |= PhysicsStateFlags.Gravity;
|
|
var mi = new MotionInterpreter(body);
|
|
var sink = new RecordingSink();
|
|
|
|
mi.MoveToInterpretedState(Ims(fwd: 0x44000007u, fwdSpd: 2.85f), sink);
|
|
|
|
// Falling (0x40000015) is ALWAYS allowed by contact_allows_move
|
|
// (0x00528240 early-accept), so it reaches the sink; the style and
|
|
// forward dispatches are gate-blocked (apply-only path).
|
|
Assert.Equal(new[] { "DIM 40000015@1.00", "STOP 6500000d" }, sink.Calls);
|
|
// #161 correction (2026-07-03): the apply pass runs its dispatches
|
|
// with ModifyInterpretedState = FALSE — retail constructs var_2c
|
|
// then CLEARS bits 11/14/15 (SetHoldKey / ModifyInterpretedState /
|
|
// CancelMoveTo) and re-sets 15/17 from the args; the BN pseudo-C
|
|
// smears that bitfield store into the mush expression at raw
|
|
// 305778 (`(word & 0x37ff) | (arg2&1)<<15 | (arg3&1)<<17`). ACE
|
|
// MotionInterp.cs:444-449 confirms independently. So NEITHER the
|
|
// blocked style dispatch NOR the Falling substitution writes
|
|
// InterpretedState — the wire's forward command survives the
|
|
// airborne pass. This is the retail landing-exit mechanism:
|
|
// HitGround's re-apply dispatches the PRESERVED command, and the
|
|
// motion table plays the Falling→X landing link. (The previous
|
|
// revision of this assertion pinned 0x40000015 — the #161 bug
|
|
// itself: the ctor-default params let the Falling dispatch clobber
|
|
// forward_command, so a stand-still landing re-dispatched Falling
|
|
// forever.)
|
|
Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand);
|
|
Assert.Equal(2.85f, mi.InterpretedState.ForwardSpeed);
|
|
}
|
|
|
|
[Fact]
|
|
public void HitGround_AfterFall_RedispatchesPreservedForward_ExitsFalling()
|
|
{
|
|
// #161 (remote jump landing stuck in the falling pose): the full
|
|
// remote lifecycle. Wire says RunForward@2.85 while grounded; the
|
|
// body leaves the ground (LeaveGround engages Falling through the
|
|
// sink WITHOUT clobbering the interpreted forward command — the
|
|
// apply pass's ModifyInterpretedState=false, raw 305778 / ACE
|
|
// MotionInterp.cs:447); on touchdown, HitGround (0x00528ac0) —
|
|
// called with GRAVITY STILL SET, its verbatim state&0x400 gate —
|
|
// re-applies the PRESERVED command, which is what makes
|
|
// GetObjectSequence play the Falling→RunForward landing link. No
|
|
// wire input is needed to exit the falling pose.
|
|
var body = new PhysicsBody();
|
|
body.State |= PhysicsStateFlags.Gravity;
|
|
body.TransientState |= TransientStateFlags.Contact
|
|
| TransientStateFlags.OnWalkable
|
|
| TransientStateFlags.Active;
|
|
var mi = new MotionInterpreter(body, new RemoteWeenie());
|
|
var sink = new RecordingSink();
|
|
mi.DefaultSink = sink; // HitGround/LeaveGround re-apply through DefaultSink
|
|
|
|
mi.MoveToInterpretedState(Ims(fwd: 0x44000007u, fwdSpd: 2.85f), sink);
|
|
Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand);
|
|
|
|
// Jump start: ground contact drops FIRST (GameWindow's VectorUpdate
|
|
// handler order), then LeaveGround re-applies → Falling engages.
|
|
body.TransientState &= ~(TransientStateFlags.Contact
|
|
| TransientStateFlags.OnWalkable);
|
|
sink.Calls.Clear();
|
|
mi.LeaveGround();
|
|
|
|
Assert.Contains(sink.Calls, c => c.StartsWith("DIM 40000015"));
|
|
Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand); // NOT clobbered
|
|
Assert.Equal(2.85f, mi.InterpretedState.ForwardSpeed);
|
|
|
|
// Touchdown: contact restored, Gravity still set (the retail
|
|
// contract — CMotionInterp::HitGround no-ops without it).
|
|
body.TransientState |= TransientStateFlags.Contact
|
|
| TransientStateFlags.OnWalkable;
|
|
sink.Calls.Clear();
|
|
mi.HitGround();
|
|
|
|
// Retail re-apply order: style (from the copy_movement_from-adopted
|
|
// interpreted current_style, raw 0051e757) → preserved forward →
|
|
// sidestep-stop → turn-stop. The forward dispatch at the wire
|
|
// command IS the falling-pose exit.
|
|
Assert.Equal(new[]
|
|
{
|
|
"DIM 8000003d@1.00",
|
|
"DIM 44000007@2.85",
|
|
"STOP 6500000f",
|
|
"STOP 6500000d",
|
|
}, sink.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void FlatCopy_OverwritesEveryAxis()
|
|
{
|
|
// copy_movement_from (0x0051e750) is a FLAT overwrite — a fresh UM
|
|
// with defaults clears a previously-set sidestep/turn.
|
|
var mi = GroundedInterp();
|
|
mi.MoveToInterpretedState(
|
|
Ims(fwd: 0x44000007u, fwdSpd: 2.85f, side: 0x6500000Fu, turn: 0x6500000Du),
|
|
new RecordingSink());
|
|
|
|
mi.MoveToInterpretedState(Ims(), new RecordingSink());
|
|
|
|
Assert.Equal(0x41000003u, mi.InterpretedState.ForwardCommand);
|
|
Assert.Equal(0u, mi.InterpretedState.SideStepCommand);
|
|
Assert.Equal(0u, mi.InterpretedState.TurnCommand);
|
|
}
|
|
|
|
[Fact]
|
|
public void RawStateStyle_AdoptedFromIms()
|
|
{
|
|
// move_to_interpreted_state head: raw_state.current_style =
|
|
// ims.current_style (305944).
|
|
var mi = GroundedInterp();
|
|
mi.MoveToInterpretedState(Ims(style: 0x8000003Cu), new RecordingSink());
|
|
Assert.Equal(0x8000003Cu, mi.RawState.CurrentStyle);
|
|
}
|
|
|
|
// ── action list: 15-bit server_action_stamp gate (305953-305989) ──────
|
|
|
|
[Fact]
|
|
public void Actions_FreshStamp_DispatchedAfterMovement_AndStampAdopted()
|
|
{
|
|
var mi = GroundedInterp();
|
|
var sink = new RecordingSink();
|
|
var actions = new[] { new InboundMotionAction(0x10000062u, Stamp: 5, Autonomous: false, Speed: 1.25f) };
|
|
|
|
mi.MoveToInterpretedState(Ims(actions: actions), sink);
|
|
|
|
Assert.Equal("DIM 10000062@1.25", sink.Calls[^1]); // after the movement dispatches
|
|
Assert.Equal(5, mi.ServerActionStamp);
|
|
}
|
|
|
|
[Fact]
|
|
public void Actions_StaleStamp_Skipped()
|
|
{
|
|
var mi = GroundedInterp();
|
|
mi.ServerActionStamp = 10;
|
|
var sink = new RecordingSink();
|
|
|
|
mi.MoveToInterpretedState(
|
|
Ims(actions: new[] { new InboundMotionAction(0x10000062u, 9, false, 1f) }), sink);
|
|
|
|
Assert.DoesNotContain(sink.Calls, c => c.StartsWith("DIM 10000062"));
|
|
Assert.Equal(10, mi.ServerActionStamp);
|
|
}
|
|
|
|
[Fact]
|
|
public void Actions_StampWrapsAt15Bits()
|
|
{
|
|
// The compare is 15-bit wraparound (mask 0x7fff, threshold 0x3fff).
|
|
var mi = GroundedInterp();
|
|
mi.ServerActionStamp = 0x7FFE;
|
|
var sink = new RecordingSink();
|
|
|
|
mi.MoveToInterpretedState(
|
|
Ims(actions: new[] { new InboundMotionAction(0x10000062u, 2, false, 1f) }), sink);
|
|
|
|
Assert.Contains(sink.Calls, c => c.StartsWith("DIM 10000062")); // 2 is newer than 0x7ffe
|
|
Assert.Equal(2, mi.ServerActionStamp);
|
|
}
|
|
|
|
[Fact]
|
|
public void Actions_AutonomousOnLocalPlayer_Skipped()
|
|
{
|
|
// Retail skips autonomous action replay on the LOCAL player (its own
|
|
// echo); remotes always apply (305977-305987).
|
|
var body = new PhysicsBody();
|
|
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
|
var mi = new MotionInterpreter(body) { IsLocalPlayer = true };
|
|
var sink = new RecordingSink();
|
|
|
|
mi.MoveToInterpretedState(
|
|
Ims(actions: new[] { new InboundMotionAction(0x10000062u, 5, Autonomous: true, 1f) }), sink);
|
|
|
|
Assert.DoesNotContain(sink.Calls, c => c.StartsWith("DIM 10000062"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Actions_ReplayCarriesAutonomyIntoTheInterpretedList()
|
|
{
|
|
// R5-V4 (#164): retail splices the ACTION's autonomy into the
|
|
// dispatch params (bit 0x1000 — raw 305982:
|
|
// `var_28 ^= ((action.autonomous << 0xc) ^ var_28) & 0x1000`), so a
|
|
// replayed action enters the interpreted actions list
|
|
// (InterpretedMotionState::AddAction consumes p.Autonomous) with its
|
|
// REAL autonomy. Pre-V4 the replay params were ctor-default →
|
|
// Autonomous always false.
|
|
var mi = GroundedInterp(); // remote posture: IsLocalPlayer = false
|
|
var sink = new RecordingSink();
|
|
|
|
mi.MoveToInterpretedState(
|
|
Ims(actions: new[] { new InboundMotionAction(0x10000062u, 5, Autonomous: true, 1f) }), sink);
|
|
|
|
var entry = Assert.Single(mi.InterpretedState.Actions, a => a.Command == 0x0062);
|
|
Assert.True(entry.Autonomous);
|
|
}
|
|
|
|
[Fact]
|
|
public void InboundState_Defaults_MatchRetailUnPack()
|
|
{
|
|
// InterpretedMotionState::UnPack absent-field defaults (0x0051f400):
|
|
// style NonCombat, fwd Ready, speeds 1.0, side/turn 0.
|
|
var d = InboundInterpretedState.Default();
|
|
Assert.Equal(0x8000003Du, d.CurrentStyle);
|
|
Assert.Equal(0x41000003u, d.ForwardCommand);
|
|
Assert.Equal(1.0f, d.ForwardSpeed);
|
|
Assert.Equal(0u, d.SideStepCommand);
|
|
Assert.Equal(1.0f, d.SideStepSpeed);
|
|
Assert.Equal(0u, d.TurnCommand);
|
|
Assert.Equal(1.0f, d.TurnSpeed);
|
|
}
|
|
}
|