// Milestone M2 scenarios (DESIGN_UE.md section 10, "Walk the carrier"): m2_walk, m2_dry, m2_exterior, m2_moving_base. // The helpers here are built from S.Do / S.Until on top of the existing scenario API (nothing in DescentScenario.* is touched). // // After the merge the scenarios have no environment of their own: ADescentEnvironment (spawned by the boot sequence) is the one // authority for exposure, fog and post-process, and the carrier interior is calibrated against it (Tune::Carrier, see Docs/status/integ.md). #include "Core/DescentMath.h" #include "Core/DescentGameMode.h" #include "Core/DescentSimSubsystem.h" #include "Core/DescentEnvQuery.h" #include "Core/Tuning/DescentTune_Carrier.h" #include "Debug/DescentDebugSubsystem.h" #include "Debug/DescentScenario.h" #include "Debug/DescentStateRegistry.h" #include "Player/DescentCharacter.h" #include "Player/DescentPlayerController.h" #include "Vehicles/CarrierBlockout.h" #include "Vehicles/CarrierLayout.h" #include "Vehicles/DescentCarrier.h" #include "UI/DescentHudSubsystem.h" #include "World/DescentTerrainSubsystem.h" #include "World/DescentWorldScenarioSteps.h" #include "DescentUnits.h" #include "TheDescent.h" #include "Components/ExponentialHeightFogComponent.h" #include "Components/PointLightComponent.h" #include "Components/PostProcessComponent.h" #include "Components/StaticMeshComponent.h" #include "Engine/StaticMesh.h" #include "Engine/World.h" #include "HAL/IConsoleManager.h" #include "Materials/MaterialInstanceDynamic.h" namespace { /** Problems found by soft checks; reported together at the end so one run shows every number. */ struct FM2Problems { TArray Items; }; ADescentCharacter* M2Char(const FDescentScenarioContext& C) { APlayerController* PC = C.World->GetFirstPlayerController(); return PC ? Cast(PC->GetPawn()) : nullptr; } FName M2RoomOf(const FDescentScenarioContext& C) { const ADescentCharacter* P = M2Char(C); const Descent::Carrier::FRoomDef* R = P ? Descent::Carrier::FLayout::Get().RoomAt(P->GetFeetLocalM() + FVector(0, 0, 0.3)) : nullptr; return R ? R->Id : NAME_None; } /** Walks with real movement input and waits until the walker is done (the command itself fails the scenario on a timeout). */ void M2MoveTo(FDescentScenarioBuilder& S, const FString& Target) { S.Exec(TEXT("Descent.Player.MoveTo ") + Target); S.Until(TEXT("arrived at ") + Target, [](FDescentScenarioContext& C) { const ADescentCharacter* P = M2Char(C); return !P || !P->GetAutoWalk().bActive; }); S.WaitSim(0.6); } /** Checks the room and the deck height the character stands at. */ void M2ExpectAt(FDescentScenarioBuilder& S, const FString& Label, const FName Room, double FeetZ) { S.Expect(Label + ": room is " + Room.ToString(), [Room](FDescentScenarioContext& C) { if (M2RoomOf(C) != Room) { C.Fail(FString::Printf(TEXT("expected room %s, standing in %s"), *Room.ToString(), *M2RoomOf(C).ToString())); return false; } return true; }); S.Expect(Label + FString::Printf(TEXT(": feet z = %.1f +- 0.05"), FeetZ), [FeetZ](FDescentScenarioContext& C) { const ADescentCharacter* P = M2Char(C); const double Z = P ? P->GetFeetLocalM().Z : -999.0; if (FMath::Abs(Z - FeetZ) > 0.05) { C.Fail(FString::Printf(TEXT("feet z %.3f, expected %.2f +- 0.05"), Z, FeetZ)); return false; } return true; }); } /** Places the character in a room, takes the shot and records a problem when the brightness is outside the band. */ void M2RoomShot(FDescentScenarioBuilder& S, TSharedRef Problems, const FName Room, double MinLuma, double MaxLuma) { S.Exec(TEXT("Descent.Player.Place ") + Room.ToString()); S.WaitSim(0.7); S.Do(TEXT("look in ") + Room.ToString(), [Room](FDescentScenarioContext& C) { const Descent::Carrier::FRoomDef* R = Descent::Carrier::FLayout::Get().FindRoom(Room); if (ADescentCharacter* P = M2Char(C)) { P->SetViewLocal(R ? R->StopYawDeg : 0.0, -8.0); } }); S.WaitFrames(2); S.Shot(TEXT("room_") + Room.ToString()); S.Do(TEXT("brightness of ") + Room.ToString(), [Problems, Room, MinLuma, MaxLuma](FDescentScenarioContext& C) { const double L = C.LastShotStats.MeanLuma; UE_LOG(LogDescent, Display, TEXT("M2 room shot %s: mean luma %.3f (band %.2f..%.2f), centre %.3f, in room %s"), *Room.ToString(), L, MinLuma, MaxLuma, C.LastShotStats.CentreLuma, *M2RoomOf(C).ToString()); if (L < MinLuma || L > MaxLuma) { Problems->Items.Add(FString::Printf(TEXT("%s mean luma %.3f outside %.2f..%.2f"), *Room.ToString(), L, MinLuma, MaxLuma)); } if (M2RoomOf(C) != Room) { Problems->Items.Add(FString::Printf(TEXT("%s: placed character is in room %s"), *Room.ToString(), *M2RoomOf(C).ToString())); } }); } void M2ReportProblems(FDescentScenarioBuilder& S, TSharedRef Problems, const FString& What) { S.Do(What, [Problems, What](FDescentScenarioContext& C) { if (Problems->Items.Num() > 0) { C.Fail(What + ": " + FString::Join(Problems->Items, TEXT("; "))); } }); } } DESCENT_SCENARIO(m2_walk, "M2: walks bridge > corr3 > stair > galley > cargo > refinery > engine > bay gallery > bay floor with real movement input; a shot of every room (16).") { S.TimeLimit(300.f); TSharedRef Problems = MakeShared(); namespace T = Descent::Tune::Carrier; S.Expect(TEXT("a carrier and a character exist"), [](FDescentScenarioContext& C) { return UDescentCarrierSubsystem::GetCarrier(C.World) && M2Char(C); }); S.Expect(TEXT("mode is Foot under a scenario"), [](FDescentScenarioContext& C) { const ADescentPlayerController* PC = Cast(C.World->GetFirstPlayerController()); return PC && PC->GetMode() == EDescentMode::Foot; }); S.WaitSim(1.0); M2ExpectAt(S, TEXT("start on the bridge"), TEXT("bridge"), T::DeckFloorZ3); M2MoveTo(S, TEXT("corr3")); M2ExpectAt(S, TEXT("corr3"), TEXT("corr3"), T::DeckFloorZ3); M2MoveTo(S, TEXT("stair_up")); M2ExpectAt(S, TEXT("stairwell landing"), TEXT("stair"), T::DeckFloorZ3); M2MoveTo(S, TEXT("stair")); M2ExpectAt(S, TEXT("stairwell foot"), TEXT("stair"), T::DeckFloorZ2); M2MoveTo(S, TEXT("galley")); M2ExpectAt(S, TEXT("galley"), TEXT("galley"), T::DeckFloorZ2); M2MoveTo(S, TEXT("cargo")); M2ExpectAt(S, TEXT("cargo"), TEXT("cargo"), T::DeckFloorZ2); M2MoveTo(S, TEXT("refinery")); M2ExpectAt(S, TEXT("refinery"), TEXT("refinery"), T::DeckFloorZ2); M2MoveTo(S, TEXT("engine")); M2ExpectAt(S, TEXT("engine"), TEXT("engine"), T::DeckFloorZ2); M2MoveTo(S, TEXT("bay_up")); M2ExpectAt(S, TEXT("bay gallery"), TEXT("bay"), T::DeckFloorZ2); M2MoveTo(S, TEXT("bay")); M2ExpectAt(S, TEXT("bay floor"), TEXT("bay"), T::DeckFloorZ1); S.Expect(TEXT("nobody fell out of the hull"), [](FDescentScenarioContext& C) { const ADescentCharacter* P = M2Char(C); return P && P->RescueCount == 0; }); static const TCHAR* const Rooms[] = { TEXT("bridge"), TEXT("q1"), TEXT("q2"), TEXT("q3"), TEXT("q4"), TEXT("hab"), TEXT("fab"), TEXT("comp"), TEXT("corr3"), TEXT("stair"), TEXT("galley"), TEXT("cargo"), TEXT("refinery"), TEXT("engine"), TEXT("corr2"), TEXT("bay") }; for (const TCHAR* R : Rooms) { M2RoomShot(S, Problems, R, 0.10, 0.45); } M2ReportProblems(S, Problems, TEXT("room shots")); // the fixture stubs show their prompt in the HUD model and answer E (bed in quarters A, nutrient port in the galley, helm on the bridge) struct FFocusCase { const TCHAR* Place; double X, Y, Z, Yaw, Pitch; const TCHAR* Prompt; }; static const FFocusCase Cases[] = { { TEXT("q1"), 32.2, -5.8, 1.5, -62.0, -30.0, TEXT("E Sleep") }, { TEXT("galley"), 24.0, -8.2, -3.0, -90.0, -7.0, TEXT("E Jack in") }, { TEXT("bridge"), 42.0, 0.0, 1.5, 0.0, -12.0, TEXT("E Take the helm") }, }; for (const FFocusCase& F : Cases) { S.Exec(FString::Printf(TEXT("Descent.Player.PlaceLocal %.2f %.2f %.2f %.1f"), F.X, F.Y, F.Z, F.Yaw)); S.WaitSim(0.6); S.Do(TEXT("look at the station"), [F](FDescentScenarioContext& C) { if (ADescentCharacter* P = M2Char(C)) { P->SetViewLocal(F.Yaw, F.Pitch); } }); S.WaitSim(0.4); S.Expect(FString::Printf(TEXT("prompt '%s...' shown in the HUD model"), F.Prompt), [F](FDescentScenarioContext& C) { const FString Prompt = UDescentHudSubsystem::Get(C.World)->Model().Prompt; if (!Prompt.StartsWith(F.Prompt)) { C.Fail(FString::Printf(TEXT("prompt is '%s'"), *Prompt)); return false; } return true; }); S.Exec(TEXT("Descent.Player.Use")); S.Expect(TEXT("E answered with a toast"), [](FDescentScenarioContext& C) { return UDescentHudSubsystem::Get(C.World)->Model().Toasts.Num() > 0; }); } S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(look_rooms, "DESIGN 7.10 look_rooms: one shot per room, on foot, under the real environment (exposure, water outside): every mean luma 0.10 - 0.45 and the environment reads Dry, 26.0 C in every room.") { S.TimeLimit(300.f); TSharedRef Problems = MakeShared(); S.Expect(TEXT("a character exists and the mode is Foot"), [](FDescentScenarioContext& C) { const ADescentPlayerController* PC = Cast(C.World->GetFirstPlayerController()); return PC && PC->GetMode() == EDescentMode::Foot && M2Char(C); }); static const TCHAR* const Rooms[] = { TEXT("bridge"), TEXT("q1"), TEXT("q2"), TEXT("q3"), TEXT("q4"), TEXT("hab"), TEXT("fab"), TEXT("comp"), TEXT("corr3"), TEXT("stair"), TEXT("galley"), TEXT("cargo"), TEXT("refinery"), TEXT("engine"), TEXT("corr2"), TEXT("bay") }; for (const TCHAR* R : Rooms) { M2RoomShot(S, Problems, R, 0.10, 0.45); S.Do(FString::Printf(TEXT("%s is dry and 26.0 C"), R), [Problems, R](FDescentScenarioContext& C) { const TSharedRef Snap = C.Snapshot(); const TSharedPtr* Env = nullptr; if (!Snap->TryGetObjectField(TEXT("env"), Env) || !Env) { Problems->Items.Add(FString::Printf(TEXT("%s: no env section"), R)); return; } if ((*Env)->GetStringField(TEXT("state")) != TEXT("Dry") || (*Env)->GetNumberField(TEXT("temperature_c")) != 26.0 || (*Env)->GetBoolField(TEXT("volumetric_fog"))) { Problems->Items.Add(FString::Printf(TEXT("%s: env %s, %.2f C, volumetric %d"), R, *(*Env)->GetStringField(TEXT("state")), (*Env)->GetNumberField(TEXT("temperature_c")), (*Env)->GetBoolField(TEXT("volumetric_fog")))); } }); } M2ReportProblems(S, Problems, TEXT("look_rooms")); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m2_exterior, "M2 (V4a): the carrier from 80 m is one large dark mass with exactly six lights.") { S.TimeLimit(120.f); namespace T = Descent::Tune::Carrier; S.WaitSim(1.5); S.Expect(TEXT("carrier.exterior_lights == 6"), [](FDescentScenarioContext& C) { const ADescentCarrier* K = UDescentCarrierSubsystem::GetCarrier(C.World); return K && K->Blockout->NumExteriorLights() == T::ExteriorLightCount; }); // With the real water (PP_Water, 0.019 per metre at this depth) a dark hull 100+ m away is fogged into the sea; the picture is taken // from about 85 m off the beam, where the hull is a dark mass against the brighter far water and the lights show. S.Do(TEXT("camera about 85 m off the starboard beam, a little above"), [](FDescentScenarioContext& C) { const ADescentCarrier* K = UDescentCarrierSubsystem::GetCarrier(C.World); if (!K) { return; } const FVector Cam = Descent::UUToMetres(K->ToWorldUU(FVector(14.0, 85.0, 14.0))); const FVector At = Descent::UUToMetres(K->ToWorldUU(FVector(14.0, 0.0, 0.0))); C.Debug->PlaceCamera(Cam, (At - Cam).Rotation(), 75.f); }); S.Shot(TEXT("exterior_dark")); S.Do(TEXT("hull region"), [](FDescentScenarioContext& C) { double Hull = 0.0; FString Err; if (!UDescentDebugSubsystem::MeasureShotRegion(C.ScenarioName + TEXT("_exterior_dark"), 0.30, 0.35, 0.70, 0.65, Hull, Err)) { C.Fail(Err); return; } UE_LOG(LogDescent, Display, TEXT("M2 exterior_dark: mean %.3f centre %.3f hull region %.3f p99 %.3f non_black %.3f"), C.LastShotStats.MeanLuma, C.LastShotStats.CentreLuma, Hull, C.LastShotStats.P99Luma, C.LastShotStats.NonBlack); if (Hull >= 0.12) { C.Fail(FString::Printf(TEXT("the hull region reads %.3f: not a dark mass (< 0.12)"), Hull)); } if (C.LastShotStats.P99Luma < 0.05) { C.Fail(TEXT("nothing is lit in the picture: the lights are missing")); } }); S.DumpState(TEXT("end")); S.Quit(); } // ===================================================================================================================== // m2_moving_base: R2 of DESIGN_UE.md section 11, proven now. The carrier is moved kinematically at the design maxima // (2.0 m/s, yaw 0.10 rad/s = 5.73 deg/s, pitch +-10 deg, roll -0.25 omega) while the character stands, walks and climbs the ramp. // ===================================================================================================================== namespace { /** What one phase measured, all in CARRIER-LOCAL coordinates. */ struct FM2Stats { bool bHave = false; FVector P0 = FVector::ZeroVector, Prev = FVector::ZeroVector; int32 N = 0; double MaxDev = 0.0, MaxDevZ = 0.0, MaxStep = 0.0, MaxAbsY = 0.0, MaxRampErr = 0.0; double MinSpeed = 1e9, MaxSpeed = 0.0, SumSpeed = 0.0; int32 SpeedN = 0; double MaxPitchDeg = 0.0, MaxRollDeg = 0.0, MaxCarrierSpeed = 0.0, MaxYawRateDps = 0.0; int32 Rescues0 = 0; }; /** The smooth collision ramp of the stairwell (DESIGN 3.3): z at x for y in 5.25..8.55. */ bool M2RampZ(const FVector& P, double& OutZ) { if (P.X < 7.95 || P.X > 18.6 || P.Y < 5.25 || P.Y > 8.55) { return false; } OutZ = -3.0 + (18.6 - P.X) / (18.6 - 7.95) * 4.5; return true; } void M2Accumulate(const FDescentScenarioContext& C, FM2Stats& St) { const ADescentCharacter* Ch = M2Char(C); const ADescentCarrier* K = UDescentCarrierSubsystem::GetCarrier(C.World); if (!Ch || !K) { return; } const FVector P = Ch->GetFeetLocalM(); const double Dt = FMath::Max(1e-4, C.Sim()->LastDt()); const FRotator R = K->GetActorRotation(); St.MaxPitchDeg = FMath::Max(St.MaxPitchDeg, FMath::Abs(R.Pitch)); St.MaxRollDeg = FMath::Max(St.MaxRollDeg, FMath::Abs(R.Roll)); St.MaxCarrierSpeed = FMath::Max(St.MaxCarrierSpeed, K->VelocityMps().Size()); St.MaxYawRateDps = FMath::Max(St.MaxYawRateDps, FMath::Abs(K->YawRateDps())); if (!St.bHave) { St.bHave = true; St.P0 = P; St.Prev = P; St.Rescues0 = Ch->RescueCount; return; } const double Step = (P - St.Prev).Size(); St.MaxStep = FMath::Max(St.MaxStep, Step); St.MaxDev = FMath::Max(St.MaxDev, (P - St.P0).Size()); St.MaxDevZ = FMath::Max(St.MaxDevZ, FMath::Abs(P.Z - St.P0.Z)); St.MaxAbsY = FMath::Max(St.MaxAbsY, FMath::Abs(P.Y)); ++St.N; if (St.N > 20) { const double V = Step / Dt; St.MinSpeed = FMath::Min(St.MinSpeed, V); St.MaxSpeed = FMath::Max(St.MaxSpeed, V); St.SumSpeed += V; ++St.SpeedN; } double RampZ = 0.0; if (M2RampZ(P, RampZ)) { St.MaxRampErr = FMath::Max(St.MaxRampErr, FMath::Abs(P.Z - RampZ)); } St.Prev = P; } void M2LogStats(const TCHAR* Phase, const FM2Stats& St) { UE_LOG(LogDescent, Display, TEXT("M2 MOVING-BASE %s: samples %d | local drift max %.4f m (z %.4f) | frame step max %.4f m | local speed min %.3f mean %.3f max %.3f m/s | max |y| %.3f | ramp z error max %.4f | carrier peak: pitch %.2f deg, roll %.2f deg, speed %.2f m/s, yaw rate %.2f deg/s"), Phase, St.N, St.MaxDev, St.MaxDevZ, St.MaxStep, St.SpeedN ? St.MinSpeed : 0.0, St.SpeedN ? St.SumSpeed / St.SpeedN : 0.0, St.MaxSpeed, St.MaxAbsY, St.MaxRampErr, St.MaxPitchDeg, St.MaxRollDeg, St.MaxCarrierSpeed, St.MaxYawRateDps); } /** Starts the design-maximum motion for Seconds (a whole number of pitch periods so the carrier ends level). */ void M2StartMotion(FDescentScenarioBuilder& S, double Seconds) { S.Exec(FString::Printf(TEXT("Descent.Carrier.Motion 2 0 0 %.4f 10 8 %.3f"), FMath::RadiansToDegrees(Descent::Tune::Carrier::MaxYawRateRadPerSec), Seconds)); } void M2WaitMotionEnd(FDescentScenarioBuilder& S) { S.Until(TEXT("the carrier motion has ended"), [](FDescentScenarioContext& C) { const ADescentCarrier* K = UDescentCarrierSubsystem::GetCarrier(C.World); return !K || !K->GetDebugMotion().bActive; }); S.WaitSim(0.8); } /** Samples a phase for a number of sim seconds while something else (motion, a walk) runs. */ void M2SampleFor(FDescentScenarioBuilder& S, const TCHAR* Label, TSharedRef St, double Seconds) { TSharedRef Start = MakeShared(-1.0); S.Until(FString(TEXT("sample ")) + Label, [St, Start, Seconds](FDescentScenarioContext& C) { if (*Start < 0.0) { *Start = C.Sim()->SimTime(); } M2Accumulate(C, *St); return C.Sim()->SimTime() - *Start >= Seconds; }); } /** Walks (real input) and samples until the walker is done. */ void M2WalkSampled(FDescentScenarioBuilder& S, const FString& Target, TSharedRef St) { S.Exec(TEXT("Descent.Player.MoveTo ") + Target); S.Until(TEXT("walk to ") + Target + TEXT(" while sampling"), [St](FDescentScenarioContext& C) { M2Accumulate(C, *St); const ADescentCharacter* P = M2Char(C); return !P || !P->GetAutoWalk().bActive; }); } } DESCENT_SCENARIO(m2_moving_base, "M2 / R2: the carrier translates 2 m/s, yaws 5.7 deg/s and pitches +-10 deg while the character stands, walks and climbs the ramp; carrier-local position must stay consistent.") { S.TimeLimit(300.f); namespace T = Descent::Tune::Carrier; TSharedRef Stand = MakeShared(), Walk = MakeShared(), Climb = MakeShared(); S.WaitSim(0.5); // ---- 1. standing on the deck of the moving, yawing, pitching carrier ---- S.Exec(TEXT("Descent.Player.Place corr3")); S.WaitSim(1.0); M2StartMotion(S, 16.0); M2SampleFor(S, TEXT("stand"), Stand, 15.0); S.Shot(TEXT("stand_moving")); // a picture mid-motion (carrier pitched, view level in the world) M2WaitMotionEnd(S); S.Do(TEXT("log stand"), [Stand](FDescentScenarioContext&) { M2LogStats(TEXT("stand"), *Stand); }); // ---- 2. walking along the upper deck corridor under way (out 16 m, back 24 m) ---- S.Exec(TEXT("Descent.Player.Place corr3")); S.WaitSim(1.0); M2StartMotion(S, 16.0); M2WalkSampled(S, TEXT("30 0"), Walk); M2WalkSampled(S, TEXT("6 0"), Walk); M2WaitMotionEnd(S); S.Do(TEXT("log walk"), [Walk](FDescentScenarioContext&) { M2LogStats(TEXT("walk"), *Walk); }); // ---- 3. climbing and descending the 22.9 degree ramp under way ---- S.Exec(TEXT("Descent.Player.Place stair")); S.WaitSim(1.0); M2StartMotion(S, 24.0); M2WalkSampled(S, TEXT("stair_up"), Climb); M2WalkSampled(S, TEXT("stair"), Climb); M2WaitMotionEnd(S); S.Do(TEXT("log climb"), [Climb](FDescentScenarioContext&) { M2LogStats(TEXT("climb"), *Climb); }); // ---- the verdict (tolerances are stated in Docs/status/carrier.md) ---- S.Expect(TEXT("nobody fell out of the hull"), [](FDescentScenarioContext& C) { const ADescentCharacter* P = M2Char(C); return P && P->RescueCount == 0; }); S.Expect(TEXT("stand: carrier-local drift <= 0.01 m and frame step <= 0.01 m"), [Stand](FDescentScenarioContext& C) { if (Stand->MaxDev > 0.01 || Stand->MaxStep > 0.01) { C.Fail(FString::Printf(TEXT("drift %.4f m, step %.4f m"), Stand->MaxDev, Stand->MaxStep)); return false; } return true; }); S.Expect(TEXT("walk: stays in the corridor and walks at 2.9 m/s without stutter"), [Walk](FDescentScenarioContext& C) { const double Mean = Walk->SpeedN ? Walk->SumSpeed / Walk->SpeedN : 0.0; if (Walk->MaxAbsY > 0.9 || Mean < 2.3 || Mean > 3.2 || Walk->MaxDevZ > 0.02) { C.Fail(FString::Printf(TEXT("max |y| %.3f, mean speed %.3f, z dev %.4f"), Walk->MaxAbsY, Mean, Walk->MaxDevZ)); return false; } return true; }); S.Expect(TEXT("climb: follows the ramp within 0.12 m and reaches both ends"), [Climb](FDescentScenarioContext& C) { if (Climb->MaxRampErr > 0.12) { C.Fail(FString::Printf(TEXT("ramp z error %.4f m"), Climb->MaxRampErr)); return false; } return true; }); S.DumpState(TEXT("end")); S.Quit(); } // ===================================================================================================================== // m2_dry (U5, U6): the dry interior and its 26 degrees, end to end since the merge: the registered dry volume, the HUD model, the // `env` section of ADescentEnvironment (state Dry, no volumetric fog, no marine snow, 26.0), and the picture comparison with // PP_Water on and off (interior pixels identical, the sea through the windows fogged). // ===================================================================================================================== namespace { bool M2LookSetExists() { return IConsoleManager::Get().FindConsoleObject(TEXT("Descent.Look.Set")) != nullptr; } FString M2Degree(const TCHAR* Label, double C) { return FDescentHudModel::FormatTemperature(C, Label); } } DESCENT_SCENARIO(m2_dry, "M2 (U5, U6): on the bridge the point is dry, reads CABIN 26.0 deg C, env.state is Dry with no volumetric fog or snow, interior pixels ignore the water post-process while the sea through the windows is fogged; 40 m outside it is water.") { S.TimeLimit(180.f); namespace T = Descent::Tune::Carrier; S.Exec(TEXT("Descent.Player.Place bridge")); S.WaitSim(1.0); S.Expect(TEXT("the carrier is the registered dry volume"), [](FDescentScenarioContext& C) { const UDescentEnvQuerySubsystem* Q = UDescentEnvQuerySubsystem::Get(C.World); return Q && Q->GetDryVolumeOwners().Contains(FName(TEXT("Carrier"))); }); S.Expect(TEXT("the eye on the bridge is dry and reads exactly 26.0 C"), [](FDescentScenarioContext& C) { const ADescentCharacter* P = M2Char(C); if (!P) { return false; } const bool bDry = Descent::IsDry(C.World, P->EyeWorldUU()); const double Temp = Descent::AmbientTemperatureAt(C.World, P->EyeWorldUU()); if (!bDry || Temp != 26.0) { C.Fail(FString::Printf(TEXT("dry %d, temperature %.3f"), bDry, Temp)); return false; } return true; }); S.Expect(TEXT("the HUD model says CABIN 26.0 C, mode Foot, depth of the eye"), [](FDescentScenarioContext& C) { const FDescentHudModel& M = UDescentHudSubsystem::Get(C.World)->Model(); const FString Want = M2Degree(TEXT("CABIN"), 26.0); if (M.TemperatureText != Want || M.Mode != FName(TEXT("Foot")) || M.DepthText.IsEmpty()) { C.Fail(FString::Printf(TEXT("temperature text '%s' (want '%s'), mode '%s', depth '%s'"), *M.TemperatureText, *Want, *M.Mode.ToString(), *M.DepthText)); return false; } return true; }); S.Expect(TEXT("every interior primitive has stencil 1 and lighting channel 1 only"), [](FDescentScenarioContext& C) { const ADescentCarrier* K = UDescentCarrierSubsystem::GetCarrier(C.World); if (!K) { return false; } const FCarrierBlockoutStats St = K->Blockout->GatherStats(); UE_LOG(LogDescent, Display, TEXT("M2 dry: %d interior primitives, %d with stencil 1, %d on lighting channel 1 only"), St.InteriorPrimitives, St.InteriorWithStencil, St.InteriorOnChannel1Only); if (St.InteriorPrimitives < 40 || St.InteriorWithStencil != St.InteriorPrimitives || St.InteriorOnChannel1Only != St.InteriorPrimitives) { C.Fail(TEXT("some interior primitive is not marked")); return false; } return true; }); S.Do(TEXT("env section (ADescentEnvironment)"), [](FDescentScenarioContext& C) { const TSharedRef Snap = C.Snapshot(); const TSharedPtr* Env = nullptr; if (!Snap->TryGetObjectField(TEXT("env"), Env) || !Env) { C.Fail(TEXT("there is no `env` state section")); return; } const FString State = (*Env)->GetStringField(TEXT("state")); const bool bVol = (*Env)->GetBoolField(TEXT("volumetric_fog")); const double Snow = (*Env)->GetNumberField(TEXT("snow_instances_visible")); const double Temp = (*Env)->GetNumberField(TEXT("temperature_c")); if (State != TEXT("Dry") || bVol || Snow != 0.0 || Temp != 26.0) { C.Fail(FString::Printf(TEXT("env on the bridge: state %s, volumetric_fog %d, snow %.0f, temperature %.2f"), *State, bVol, Snow, Temp)); } }); S.Do(TEXT("look at the bow windows"), [](FDescentScenarioContext& C) { if (ADescentCharacter* P = M2Char(C)) { P->SetViewLocal(25.0, -8.0); } }); S.WaitFrames(2); // Since 2026-10-11 the player's own hands (the Twinblast body) are in the lower part of this picture and move with the Idle // animation. The two shots are compared pixel region against pixel region, so the body is held still for them. The comparison // then covers the body too: its pixels carry the "dry" stencil and must not change with the water post-process either. S.Do(TEXT("hold the body still for the two shots"), [](FDescentScenarioContext& C) { if (ADescentCharacter* P = M2Char(C)) { P->CustomTimeDilation = 0.f; } }); S.WaitFrames(2); S.Shot(TEXT("bridge_water_on")); { S.Exec(TEXT("Descent.Look.Set WaterPP 0")); S.WaitFrames(3); S.Shot(TEXT("bridge_water_off")); S.Do(TEXT("let the body go on"), [](FDescentScenarioContext& C) { if (ADescentCharacter* P = M2Char(C)) { P->CustomTimeDilation = 1.f; } }); S.Do(TEXT("compare interior and window regions of the two shots"), [](FDescentScenarioContext& C) { double InOn = 0, InOff = 0, WinOn = 0, WinOff = 0; FString Err; const bool bOk = UDescentDebugSubsystem::MeasureShotRegion(C.ScenarioName + TEXT("_bridge_water_on"), 0.05, 0.60, 0.95, 0.98, InOn, Err) && UDescentDebugSubsystem::MeasureShotRegion(C.ScenarioName + TEXT("_bridge_water_off"), 0.05, 0.60, 0.95, 0.98, InOff, Err) && UDescentDebugSubsystem::MeasureShotRegion(C.ScenarioName + TEXT("_bridge_water_on"), 0.10, 0.05, 0.90, 0.30, WinOn, Err) && UDescentDebugSubsystem::MeasureShotRegion(C.ScenarioName + TEXT("_bridge_water_off"), 0.10, 0.05, 0.90, 0.30, WinOff, Err); if (!bOk) { C.Fail(Err); return; } UE_LOG(LogDescent, Display, TEXT("M2 dry: interior region on %.4f off %.4f (diff %.4f); window region on %.4f off %.4f (diff %.4f)"), InOn, InOff, FMath::Abs(InOn - InOff), WinOn, WinOff, FMath::Abs(WinOn - WinOff)); if (InOn < 0.05) { C.Fail(TEXT("the interior region is nearly black: comparing it proves nothing")); } if (FMath::Abs(InOn - InOff) >= 0.01) { C.Fail(TEXT("the interior region changed with the water post-process: water effects reach interior pixels")); } if (FMath::Abs(WinOn - WinOff) <= 0.01) { C.Fail(TEXT("the sea seen through the windows is not fogged by the water post-process")); } }); } // 40 m outside the hull: water S.Do(TEXT("camera 40 m outside the hull"), [](FDescentScenarioContext& C) { const ADescentCarrier* K = UDescentCarrierSubsystem::GetCarrier(C.World); if (!K) { return; } const FVector Cam = Descent::UUToMetres(K->ToWorldUU(FVector(10.0, 52.0, 0.0))); // hull radius 12 + 40 const FVector At = Descent::UUToMetres(K->ToWorldUU(FVector(10.0, 0.0, 0.0))); C.Debug->PlaceCamera(Cam, (At - Cam).Rotation(), 60.f); const FVector CamUU = Descent::MetresToUU(Cam); const double Want = UDescentEnvQuerySubsystem::WaterTemperatureC(Descent::DepthOfZ(Cam.Z)); const double Got = Descent::AmbientTemperatureAt(C.World, CamUU); if (Descent::IsDry(C.World, CamUU) || FMath::Abs(Got - Want) > 1e-9) { C.Fail(FString::Printf(TEXT("40 m outside: dry %d, temperature %.4f (water formula %.4f)"), Descent::IsDry(C.World, CamUU), Got, Want)); } const FVector Below = Descent::MetresToUU(Descent::UUToMetres(K->ToWorldUU(FVector(-29.0, 6.0, -9.3)))); // 1 m under the pool surface if (Descent::IsDry(C.World, Below)) { C.Fail(TEXT("1 m below the moon pool surface reads dry")); } }); S.WaitSim(0.3); S.DumpState(TEXT("end")); S.Quit(); } // ===================================================================================================================== // m12_boot: a normal start (milestone M12 "a normal start works"). Nothing is placed, teleported or spawned by the scenario: the boot // sequence of DESIGN 1.5 (world definition, environment, terrain, carrier, character on the bridge, first mode) is all there is, as when // Play is pressed. It then looks: out of the bridge windows, at the bay with the moon pool, and at the carrier from the canyon. // ===================================================================================================================== DESCENT_SCENARIO(m12_boot, "A normal start, nothing placed by the scenario: the character stands on the bridge in mode Foot, dry at 26.0 C, the carrier sits at the start pose in the canyon with the terrain meshed around it and water outside; shots out of the bridge windows, of the bay and moon pool, and of the carrier from outside.") { S.TimeLimit(300.f); namespace T = Descent::Tune::Carrier; S.WaitSim(1.0); S.Expect(TEXT("boot report: every boot step ran and passed"), [](FDescentScenarioContext& C) { const ADescentGameMode* GM = Cast(C.World->GetAuthGameMode()); return GM && GM->GetBootReport().bRan && GM->GetBootReport().bOk; }); S.Expect(TEXT("the mode is Foot (a scenario skips the title) and the HUD model agrees"), [](FDescentScenarioContext& C) { const ADescentPlayerController* PC = Cast(C.World->GetFirstPlayerController()); return PC && PC->GetMode() == EDescentMode::Foot && UDescentHudSubsystem::Get(C.World)->Model().Mode == FName(TEXT("Foot")); }); S.Expect(TEXT("the character stands on the bridge, feet at deck 3 height"), [](FDescentScenarioContext& C) { const ADescentCharacter* P = M2Char(C); if (!P || M2RoomOf(C) != FName(TEXT("bridge"))) { C.Fail(FString::Printf(TEXT("room is %s"), *M2RoomOf(C).ToString())); return false; } return FMath::Abs(P->GetFeetLocalM().Z - T::DeckFloorZ3) < 0.05; }); S.Expect(TEXT("the carrier sits at the start pose (0, 0, -30), yaw 0"), [](FDescentScenarioContext& C) { const ADescentCarrier* K = UDescentCarrierSubsystem::GetCarrier(C.World); return K && Descent::UUToMetres(K->GetActorLocation()).Equals(FVector(0, 0, T::StartZM), 0.01) && FMath::Abs(FRotator::NormalizeAxis(K->GetActorRotation().Yaw)) < 0.01; }); S.Expect(TEXT("env.state is Dry on the bridge, 26.0 C, no volumetric fog"), [](FDescentScenarioContext& C) { const TSharedRef Snap = C.Snapshot(); const TSharedPtr* Env = nullptr; if (!Snap->TryGetObjectField(TEXT("env"), Env) || !Env) { return false; } return (*Env)->GetStringField(TEXT("state")) == TEXT("Dry") && (*Env)->GetNumberField(TEXT("temperature_c")) == 26.0 && !(*Env)->GetBoolField(TEXT("volumetric_fog")); }); DescentWorldScn::WaitStreamIdle(S); S.Expect(TEXT("the terrain around the carrier is meshed: every chunk with a surface within 100 m of the start pose has its mesh"), [](FDescentScenarioContext& C) { UDescentTerrainSubsystem* Tr = UDescentTerrainSubsystem::Get(C.World); if (!Tr) { return false; } int32 WithSurface = 0, Unmeshed = 0; const FVector Centre(0.0, 0.0, T::StartZM); for (int32 Z = -7; Z <= 7; ++Z) for (int32 Y = -7; Y <= 7; ++Y) for (int32 X = -7; X <= 7; ++X) { const FIntVector K = UDescentTerrainSubsystem::KeyOfPoint(Centre) + FIntVector(X, Y, Z); if (FVector::Dist((FVector(K) + FVector(0.5)) * 20.0, Centre) > 100.0) { continue; } const FTerrainChunk* Ch = Tr->FindChunk(K); if (!Ch || Ch->State == Descent::World::EChunkState::Uniform || Ch->State == Descent::World::EChunkState::Empty) { continue; } ++WithSurface; if (!Ch->bMeshed) { ++Unmeshed; } } UE_LOG(LogDescent, Display, TEXT("M12 boot: %d chunks with a surface within 100 m of the carrier, %d unmeshed; %d meshed in all, %d triangles"), WithSurface, Unmeshed, Tr->GetStats().ChunksMeshed, Tr->GetStats().Triangles); if (WithSurface < 20) { C.Fail(TEXT("fewer than 20 surface chunks near the carrier: the terrain was not streamed there")); return false; } return Unmeshed == 0; }); S.WaitFrames(3); S.Shot(TEXT("bridge_forward")); // exactly the view the player has after pressing Play: out of the bow windows S.Expect(TEXT("the view forward is lit (the bridge, the sea in the windows)"), [](FDescentScenarioContext& C) { return C.LastShotStats.MeanLuma > 0.05 && C.LastShotStats.NonBlack > 0.5; }); // the same place, looking a little up and to starboard: more of the window and the sea in it S.Do(TEXT("look up at the windows"), [](FDescentScenarioContext& C) { if (ADescentCharacter* P = M2Char(C)) { P->SetViewLocal(25.0, 8.0); } }); S.WaitFrames(2); S.Shot(TEXT("bridge_window")); S.Exec(TEXT("Descent.Player.Place bay")); S.WaitSim(0.7); S.Do(TEXT("look at the moon pool"), [](FDescentScenarioContext& C) { const Descent::Carrier::FRoomDef* R = Descent::Carrier::FLayout::Get().FindRoom(TEXT("bay")); if (ADescentCharacter* P = M2Char(C)) { P->SetViewLocal(R ? R->StopYawDeg : 0.0, -12.0); } }); S.WaitFrames(2); S.Shot(TEXT("bay_pool")); S.Expect(TEXT("the bay shot is in the band of the room shots (0.10 - 0.45)"), [](FDescentScenarioContext& C) { return C.LastShotStats.MeanLuma > 0.10 && C.LastShotStats.MeanLuma < 0.45; }); S.Do(TEXT("camera in the canyon, 85 m off the beam"), [](FDescentScenarioContext& C) { const ADescentCarrier* K = UDescentCarrierSubsystem::GetCarrier(C.World); if (!K) { return; } const FVector Cam = Descent::UUToMetres(K->ToWorldUU(FVector(14.0, 85.0, 14.0))); const FVector At = Descent::UUToMetres(K->ToWorldUU(FVector(14.0, 0.0, 0.0))); C.Debug->PlaceCamera(Cam, (At - Cam).Rotation(), 75.f); }); S.WaitSim(0.5); S.Expect(TEXT("seen from outside: env.state is Water"), [](FDescentScenarioContext& C) { const TSharedRef Snap = C.Snapshot(); const TSharedPtr* Env = nullptr; return Snap->TryGetObjectField(TEXT("env"), Env) && Env && (*Env)->GetStringField(TEXT("state")) == TEXT("Water"); }); S.Shot(TEXT("carrier_outside")); S.DumpState(TEXT("end")); S.Quit(); }