// Milestone M10 scenarios (DESIGN_UE.md section 10, "Interface complete"): m10_ui_layout, m10_ui_windows (V13), m10_bloom (U12), m10_look (V1), m10_perf, m10_full_run. #include "Debug/DescentScenarioUtil.h" #include "Core/DescentSimSubsystem.h" #include "Player/DescentInteraction.h" #include "Systems/DescentBuild.h" #include "Systems/DescentCargo.h" #include "Systems/DescentCraft.h" #include "Life/DescentFauna.h" #include "Systems/DescentHorror.h" #include "UI/DescentHudLayout.h" #include "UI/DescentHudSubsystem.h" #include "UI/DescentUiSubsystem.h" #include "Vehicles/CarrierLayout.h" #include "Vehicles/TenderFlight.h" #include "World/DescentTerrainSubsystem.h" #include "World/DescentWorldDef.h" #include "World/DescentWorldScenarioSteps.h" #include "DescentUnits.h" #include "Engine/Engine.h" #include "Engine/GameViewportClient.h" #include "UnrealClient.h" namespace { using namespace Descent::Scn; AKazahTender* M10Tender(const FDescentScenarioContext& C) { const UDescentTenderFleet* F = UDescentTenderFleet::Get(C.World); return F ? F->At(0) : nullptr; } UDescentCargoSubsystem* M10Cargo(const FDescentScenarioContext& C) { return UDescentCargoSubsystem::Get(C.World); } void M10WaitMode(FDescentScenarioBuilder& S, EDescentMode M) { S.Until(FString::Printf(TEXT("mode becomes %s"), DescentModeName(M)), [M](FDescentScenarioContext& C) { const ADescentPlayerController* P = PC(C); if (P && P->GetMode() == M) { return true; } if (C.SecondsInStep() > 40.0) { C.Fail(FString::Printf(TEXT("mode did not become %s"), DescentModeName(M))); return true; } return false; }); } void M10Launch(FDescentScenarioBuilder& S) { S.Exec(TEXT("Descent.Player.Place bay")); S.Exec(TEXT("Descent.Tender.Board")); M10WaitMode(S, EDescentMode::Docked); S.Exec(TEXT("Descent.Tender.Launch")); M10WaitMode(S, EDescentMode::Sub); } /** The real HUD model of this moment, with a radio line and a prompt added (the fullest case), laid out at the three sizes of the design. */ void M10ExpectLayout(FDescentScenarioBuilder& S, const FString& Case) { S.WaitFrames(10); S.Expect(FString::Printf(TEXT("%s: no two HUD regions intersect at 1280x720, 1000x560, 800x450"), *Case), [Case](FDescentScenarioContext& C) { FDescentHudModel M = UDescentHudSubsystem::Get(C.World)->Model(); M.SetRadio(TEXT("Tethys control to CANTICLE: the contract clock is running."), 6.f); if (M.Prompt.IsEmpty()) { M.Prompt = TEXT("E Use"); } bool bClean = true; for (const FVector2f& Size : { FVector2f(1280.f, 720.f), FVector2f(1000.f, 560.f), FVector2f(800.f, 450.f) }) { const FDescentHudLayout L = FDescentHudLayout::Compute(Size, M); FName A, B; const bool bOverlap = L.FindOverlap(A, B); UE_LOG(LogDescent, Display, TEXT("M10 layout: %s at %.0fx%.0f: %d visible regions, %s%s%s"), *Case, Size.X, Size.Y, L.VisibleRects().Num(), bOverlap ? TEXT("OVERLAP ") : TEXT("clean"), bOverlap ? *A.ToString() : TEXT(""), bOverlap ? *(TEXT(" / ") + B.ToString()) : TEXT("")); bClean &= !bOverlap; } return bClean; }); } struct FM10Perf { TArray FrameMs; int32 MaxTris = 0; double T0 = 0.0, Game = 0.0, Render = 0.0; int32 N = 0; bool bStarted = false; }; } DESCENT_SCENARIO(m10_ui_layout, "V13: with the real game in Foot, Sub, helm chase and helm bridge, no two of radio, prompt, vitals, hotbar, vehicle, objective and depth intersect at 1280x720, 1000x560 and 800x450. A shot per case.") { S.TimeLimit(420.f); S.Exec(TEXT("Descent.Inventory.Give flare 5")); S.Exec(TEXT("Descent.Hotbar.Assign 2 flare")); S.Exec(TEXT("Descent.Player.Place fab")); M10ExpectLayout(S, TEXT("Foot")); HudShot(S, TEXT("foot")); S.Exec(TEXT("Descent.Player.PlaceLocal 42.6 0 1.5 0")); S.Exec(TEXT("Descent.Player.Look 0 -12")); S.WaitSim(0.5); S.Exec(TEXT("Descent.Player.Use")); M10WaitMode(S, EDescentMode::Helm); M10ExpectLayout(S, TEXT("helm bridge")); HudShot(S, TEXT("helm_bridge")); S.Exec(TEXT("Descent.Carrier.View 1")); M10ExpectLayout(S, TEXT("helm chase")); HudShot(S, TEXT("helm_chase")); S.Exec(TEXT("Descent.Carrier.View 0")); S.Exec(TEXT("Descent.Player.Use")); M10WaitMode(S, EDescentMode::Foot); M10Launch(S); M10ExpectLayout(S, TEXT("Sub")); HudShot(S, TEXT("sub")); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m10_ui_windows, "V13: every window, opened on the real game: title, pause, PDA (4 tabs), fabricator, refinery, packer, storage, terminal, vat, build menu, end card. Each fits the viewport at 1280x720 and at 800x450. A shot of each.") { S.TimeLimit(420.f); S.Exec(TEXT("Descent.Store.Give ingot 12")); S.Exec(TEXT("Descent.Store.Give wafer 4")); S.Exec(TEXT("Descent.Build.Give table 2")); S.Exec(TEXT("Descent.Cargo.SpawnBox ferrite 6")); S.Exec(TEXT("Descent.Inventory.Give flare 5")); S.WaitSim(0.5); struct FWin { EDescentWindow W; const TCHAR* Tab; const TCHAR* Label; }; static const FWin Wins[] = { { EDescentWindow::Title, TEXT(""), TEXT("title") }, { EDescentWindow::Pause, TEXT(""), TEXT("pause") }, { EDescentWindow::Pda, TEXT("inventory"), TEXT("pda_inventory") }, { EDescentWindow::Pda, TEXT("map"), TEXT("pda_map") }, { EDescentWindow::Pda, TEXT("logs"), TEXT("pda_logs") }, { EDescentWindow::Pda, TEXT("status"), TEXT("pda_status") }, { EDescentWindow::Fabricator, TEXT("build"), TEXT("fabricator") }, { EDescentWindow::Refinery, TEXT(""), TEXT("refinery") }, { EDescentWindow::Packer, TEXT(""), TEXT("packer") }, { EDescentWindow::Storage, TEXT(""), TEXT("storage") }, { EDescentWindow::Terminal, TEXT(""), TEXT("terminal") }, { EDescentWindow::Vat, TEXT(""), TEXT("vat") }, { EDescentWindow::Build, TEXT(""), TEXT("build_menu") }, { EDescentWindow::EndCard, TEXT(""), TEXT("endcard") } }; for (const FWin& Win : Wins) { const FWin K = Win; S.Do(FString::Printf(TEXT("open %s"), K.Label), [K](FDescentScenarioContext& C) { UDescentUiSubsystem* Ui = UDescentUiSubsystem::Get(C.World); Ui->Close(); if (K.W == EDescentWindow::Build) { UDescentBuildSubsystem::Get(C.World)->OpenMenu(); } else if (!Ui->Open(K.W, FName(K.Tab))) { C.Fail(FString::Printf(TEXT("window %s did not open"), K.Label)); } }); S.WaitSim(K.W == EDescentWindow::Pause ? 0.0 : 1.2); // the systems fill their windows within a second S.WaitFrames(6); S.Expect(FString::Printf(TEXT("%s is open and fits 1280x720 and 800x450"), K.Label), [K](FDescentScenarioContext& C) { UDescentUiSubsystem* Ui = UDescentUiSubsystem::Get(C.World); const FVector2f Design = Descent::Ui::WindowDesignSize(K.W); bool bFits = Ui->GetWindow() == K.W; for (const FVector2f& Size : { FVector2f(1280.f, 720.f), FVector2f(800.f, 450.f) }) { const Descent::Ui::FWindowFit Fit = Descent::Ui::FitWindow(Design, Size); const bool bIn = Fit.bFits && Fit.Pos.X >= 0.f && Fit.Pos.Y >= 0.f && Fit.Pos.X + Fit.Size.X <= Size.X + 0.5f && Fit.Pos.Y + Fit.Size.Y <= Size.Y + 0.5f; if (!bIn) { UE_LOG(LogDescent, Display, TEXT("M10 windows: %s does NOT fit %.0fx%.0f (design %.0fx%.0f, scale %.2f)"), K.Label, Size.X, Size.Y, Design.X, Design.Y, Fit.Scale); } bFits &= bIn; } UE_LOG(LogDescent, Display, TEXT("M10 windows: %s open %d, tab '%s', design %.0fx%.0f, fits %d"), K.Label, Ui->GetWindow() == K.W, *Ui->GetTab().ToString(), Design.X, Design.Y, bFits); return bFits; }); HudShot(S, FString(TEXT("win_")) + K.Label); } S.Do(TEXT("close"), [](FDescentScenarioContext& C) { UDescentUiSubsystem::Get(C.World)->Close(); }); S.Expect(TEXT("the windows show the game's own numbers"), [](FDescentScenarioContext& C) { const FDescentUiModels& M = UDescentUiSubsystem::Get(C.World)->Models(); UE_LOG(LogDescent, Display, TEXT("M10 windows: storage used %d / %d, kits listed %d; build menu kits %d; status lines %d; fabricator tabs %d; pause keys %d"), M.Storage.Used, M.Storage.Cap, M.Storage.Kits.Num(), M.Build.Kits.Num(), M.Status.Lines.Num(), M.Fabricator.Categories.Num(), M.Pause.Keys.Num()); return M.Storage.Used == 16 && M.Storage.Kits.Num() == 1 && M.Build.Kits.Num() == 12 && M.Status.Lines.Num() >= 6 && M.Fabricator.Categories.Num() == 6 && M.Pause.Keys.Num() >= 15; }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m10_bloom, "U12: the pause window's Bloom slider changes env.bloom_intensity. Shots dash_bloom and bay_lamps_bloom at the default.") { S.TimeLimit(240.f); TSharedRef Before = MakeShared(0.0); S.Expect(TEXT("the slider is bound"), [Before](FDescentScenarioContext& C) { UDescentUiSubsystem* Ui = UDescentUiSubsystem::Get(C.World); *Before = SectionNumber(C.World, TEXT("env"), TEXT("bloom_intensity")); UE_LOG(LogDescent, Display, TEXT("M10 bloom: slider available %d, value %.2f of %.2f, env.bloom_intensity %.4f"), Ui->IsBloomAvailable(), Ui->GetBloomValue(), Ui->GetBloomMax(), *Before); return Ui->IsBloomAvailable(); }); S.Exec(TEXT("Descent.Player.Place bay")); S.Exec(TEXT("Descent.Tender.Board")); M10WaitMode(S, EDescentMode::Docked); S.WaitSim(1.0); HudShot(S, TEXT("dash_bloom")); S.Exec(TEXT("Descent.Tender.Exit")); M10WaitMode(S, EDescentMode::Foot); LocalShot(S, TEXT("bay_lamps_bloom"), FVector(-20.5, -6.0, -5.5), FVector(-30.0, 2.0, -2.0), 80.f); S.Do(TEXT("slider to 0"), [](FDescentScenarioContext& C) { UDescentUiSubsystem::Get(C.World)->SetBloomValue(0.f); }); S.WaitFrames(4); S.Expect(TEXT("at 0 env.bloom_intensity is 0"), [](FDescentScenarioContext& C) { const double V = SectionNumber(C.World, TEXT("env"), TEXT("bloom_intensity")); UE_LOG(LogDescent, Display, TEXT("M10 bloom: slider 0 -> env.bloom_intensity %.4f"), V); return FMath::Abs(V) < 1e-6; }); S.Do(TEXT("slider to half"), [](FDescentScenarioContext& C) { UDescentUiSubsystem* Ui = UDescentUiSubsystem::Get(C.World); Ui->SetBloomValue(0.5f * Ui->GetBloomMax()); }); S.WaitFrames(4); S.Expect(TEXT("at half it is half of the default (the top of the slider is the default)"), [Before](FDescentScenarioContext& C) { const double V = SectionNumber(C.World, TEXT("env"), TEXT("bloom_intensity")); UE_LOG(LogDescent, Display, TEXT("M10 bloom: slider half -> env.bloom_intensity %.4f (default %.4f)"), V, *Before); return FMath::Abs(V - 0.5 * *Before) < 0.01; }); S.Do(TEXT("slider back to 1"), [](FDescentScenarioContext& C) { UDescentUiSubsystem::Get(C.World)->SetBloomValue(1.f); }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m10_look, "V1 (look_rooms): every room of the carrier seen from its stop point is lit and not blown out (mean luma 0.03 to 0.75). A shot per room.") { S.TimeLimit(420.f); for (const Descent::Carrier::FRoomDef& R : Descent::Carrier::FLayout::Get().Rooms) { const FString Id = R.Id.ToString(); S.Exec(TEXT("Descent.Player.Place ") + Id); S.WaitFrames(6); S.Shot(TEXT("room_") + Id); S.Expect(FString::Printf(TEXT("%s is lit and not blown out"), *Id), [Id](FDescentScenarioContext& C) { UE_LOG(LogDescent, Display, TEXT("M10 look: room %s mean luma %.3f"), *Id, C.LastShotStats.MeanLuma); return C.LastShotStats.MeanLuma >= 0.03 && C.LastShotStats.MeanLuma <= 0.75; }); } S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m10_perf, "A tour of nine places in the Tender: the average frame takes 16.7 ms or less (run at 1920x1080) and the terrain never has more than 900,000 triangles.") { S.TimeLimit(900.f); M10Launch(S); TSharedRef P = MakeShared(); for (const TCHAR* Place : { TEXT("canyon_mid"), TEXT("hollow"), TEXT("h1"), TEXT("grotto"), TEXT("void"), TEXT("kilo7"), TEXT("hairpin"), TEXT("undercroft"), TEXT("gullet") }) { const FString Name = Place; S.Exec(TEXT("Descent.Tender.Teleport ") + Name); S.Do(TEXT("keep the Tender whole"), [](FDescentScenarioContext& C) { AKazahTender* T = M10Tender(C); T->Flight->Upgrades.DepthMk = 2; T->Flight->HullHP = T->Flight->Stats().HullMax; T->Flight->PowerPct = 100.0; T->SetHeadlight(FPlatformMisc::GetEnvironmentVariable(TEXT("DESCENT_PERF_LAMP")) != TEXT("0")); }); DescentWorldScn::WaitStreamIdle(S, 120.f); S.WaitSeconds(5.f); // the frames right after a teleport of hundreds of metres build shadow and mesh caches: not what a player flies through S.Until(TEXT("measure ") + Name, [P, Name](FDescentScenarioContext& C) { if (!P->bStarted) { P->bStarted = true; P->T0 = FPlatformTime::Seconds(); P->N = 0; P->Game = P->Render = 0.0; return false; } P->Game += FPlatformTime::ToMilliseconds(GGameThreadTime); P->Render += FPlatformTime::ToMilliseconds(GRenderThreadTime); if (++P->N < 90) { return false; } const double Ms = (FPlatformTime::Seconds() - P->T0) * 1000.0 / P->N; const UDescentTerrainSubsystem::FStats TS = UDescentTerrainSubsystem::Get(C.World)->GetStats(); P->FrameMs.Add(Ms); P->MaxTris = FMath::Max(P->MaxTris, static_cast(TS.Triangles)); P->bStarted = false; const FIntPoint View = GEngine && GEngine->GameViewport && GEngine->GameViewport->Viewport ? GEngine->GameViewport->Viewport->GetSizeXY() : FIntPoint::ZeroValue; UE_LOG(LogDescent, Display, TEXT("M10 perf: %s: frame %.2f ms (game thread %.2f, render thread %.2f) at %dx%d, terrain triangles %d, chunks meshed %d, lamp %d, leviathan %d, fish %d, homes %d"), *Name, Ms, P->Game / P->N, P->Render / P->N, View.X, View.Y, static_cast(TS.Triangles), TS.ChunksMeshed, M10Tender(C)->Flight->bLamp, UDescentHorrorSubsystem::Get(C.World)->IsLeviathanActive(), UDescentFaunaSubsystem::Get(C.World)->FishAlive(), UDescentFaunaSubsystem::Get(C.World)->ActiveHomes()); return true; }); } S.Expect(TEXT("perf.frame_ms_avg <= 16.7 and terrain.triangles <= 900,000"), [P](FDescentScenarioContext& C) { double Sum = 0.0, Worst = 0.0; for (double V : P->FrameMs) { Sum += V; Worst = FMath::Max(Worst, V); } const double Avg = P->FrameMs.Num() ? Sum / P->FrameMs.Num() : 1e9; UE_LOG(LogDescent, Display, TEXT("M10 perf: %d places, frame average %.2f ms, worst place %.2f ms, most terrain triangles %d"), P->FrameMs.Num(), Avg, Worst, P->MaxTris); return P->FrameMs.Num() == 9 && Avg <= 16.7 && P->MaxTris <= 900000; }); S.DumpState(TEXT("end")); S.Quit(); } DESCENT_SCENARIO(m10_full_run, "The whole game once: board, launch, mine, dock, the ore through the pipes into a box, the box by hand into the refinery, ingots, one sold; then route 1 is dug and the autopilot takes the carrier to the end of it; the ending and the end card. No command fails on the way.") { S.TimeLimit(1500.f); DescentWorldScn::WaitStreamIdle(S, 60.f); M10Launch(S); S.Do(TEXT("mine at least 6 ferrite"), [](FDescentScenarioContext& C) { AKazahTender* T = M10Tender(C); const Descent::World::FDescentWorldDef& Def = Descent::World::FDescentWorldDef::Get(); const FVector From = Descent::UUToMetres(T->GetActorLocation()); TArray Order; for (int32 I = 0; I < Def.Deposits.Num(); ++I) { if (static_cast(Def.Deposits[I].Type) == 0) { Order.Add(I); } } Order.Sort([&](int32 A, int32 B) { return FVector::DistSquared(Def.Deposits[A].P, From) < FVector::DistSquared(Def.Deposits[B].P, From); }); for (int32 K = 0; K < FMath::Min(4, Order.Num()) && T->Flight->HoldOrder.Num() < 6; ++K) { const Descent::World::FDeposit& D = Def.Deposits[Order[K]]; const FVector At = D.P + D.N.GetSafeNormal() * 7.0; T->SetActorLocationAndRotation(Descent::MetresToUU(At), (D.P - At).Rotation(), false, nullptr, ETeleportType::TeleportPhysics); T->Flight->VelMps = FVector::ZeroVector; T->Flight->SyncFromActor(); T->Flight->DebugDrill = 1; C.Sim()->FastForward(9.0); T->Flight->DebugDrill = -1; T->Flight->DebugSuck = 1; C.Sim()->FastForward(11.0); T->Flight->DebugSuck = -1; } T->Flight->HullHP = T->Flight->Stats().HullMax; UE_LOG(LogDescent, Display, TEXT("M10 run: mined, hold %d"), T->Flight->HoldOrder.Num()); if (T->Flight->HoldOrder.Num() < 6) { C.Fail(FString::Printf(TEXT("only %d ferrite mined"), T->Flight->HoldOrder.Num())); } }); S.Exec(FString::Printf(TEXT("Descent.Tender.Teleport %.1f %.1f %.1f"), -29.0, 6.0, Descent::Tune::Carrier::StartZM - 20.0)); S.Exec(TEXT("Descent.Player.Use")); M10WaitMode(S, EDescentMode::Docked); S.Until(TEXT("the packer makes a box"), [](FDescentScenarioContext& C) { if (C.SecondsInStep() > 90.0) { C.Fail(TEXT("no box after 90 s")); return true; } return M10Cargo(C)->Boxes().Num() >= 1 && !M10Cargo(C)->IsUnloading(); }); S.Exec(TEXT("Descent.Tender.Exit")); M10WaitMode(S, EDescentMode::Foot); // the box by hand: E at the outfeed, E at the hopper S.Exec(TEXT("Descent.Player.PlaceLocal 15.7 -6.2 -3 0")); S.Exec(TEXT("Descent.Player.Look 0 -22")); S.WaitSim(0.5); S.Exec(TEXT("Descent.Player.Use")); S.WaitSim(0.3); S.Expect(TEXT("the box is in the hands"), [](FDescentScenarioContext& C) { UE_LOG(LogDescent, Display, TEXT("M10 run: carrying %d, objective '%s'"), M10Cargo(C)->Carried() != nullptr, *UDescentHudSubsystem::Get(C.World)->Model().Objective); return M10Cargo(C)->Carried() != nullptr; }); S.Exec(TEXT("Descent.Player.PlaceLocal -2 -3.0 -3 -90")); S.Exec(TEXT("Descent.Player.Look -90 -5")); S.WaitSim(0.5); S.Exec(TEXT("Descent.Player.Use")); S.WaitSim(0.4); S.Expect(TEXT("the refinery took the six pieces"), [](FDescentScenarioContext& C) { const ADescentRefinery* R = M10Cargo(C)->GetRefineries().Num() ? M10Cargo(C)->GetRefineries()[0].Get() : nullptr; UE_LOG(LogDescent, Display, TEXT("M10 run: prompt '%s', refinery queue %d, carrying %d"), *Char(C)->Interaction->GetFocusLabel(), R ? R->Queue.Num() : -1, M10Cargo(C)->Carried() != nullptr); return R && R->Queue.Num() >= 5 && !M10Cargo(C)->Carried(); }); S.FastForward(95.0); S.Expect(TEXT("six ingots in storage"), [](FDescentScenarioContext& C) { UE_LOG(LogDescent, Display, TEXT("M10 run: ingots %d"), M10Cargo(C)->Refined[0]); return M10Cargo(C)->Refined[0] >= 6; }); S.Exec(TEXT("Descent.Sell ingot 1")); // the carrier goes down route 1 S.Exec(TEXT("Descent.World.ClearRoute 1")); S.Exec(TEXT("Descent.Player.Place bridge")); S.Exec(TEXT("Descent.Carrier.Autopilot 1")); S.Do(TEXT("the autopilot flies route 1"), [](FDescentScenarioContext& C) { ADescentCarrier* K = Carrier(C); double T = 0.0; while (T < 4000.0 && !K->Autopilot.bDone && !K->Autopilot.bStalled) { C.Sim()->FastForward(20.0); T += 20.0; } UE_LOG(LogDescent, Display, TEXT("M10 run: autopilot after %.0f s of sim: done %d, stalled %d, waypoint %d, carrier at (%.0f, %.0f, %.0f), hull %.1f"), T, K->Autopilot.bDone, K->Autopilot.bStalled, K->Autopilot.Index, K->Helm.PosM.X, K->Helm.PosM.Y, K->Helm.PosM.Z, K->Helm.Hull); if (!K->Autopilot.bDone) { C.Fail(TEXT("the autopilot did not reach the end of route 1")); } }); S.FastForward(18.0); S.WaitFrames(4); S.Expect(TEXT("the ending ran and the end card is open"), [](FDescentScenarioContext& C) { const UDescentHorrorSubsystem* H = UDescentHorrorSubsystem::Get(C.World); UDescentUiSubsystem* Ui = UDescentUiSubsystem::Get(C.World); UE_LOG(LogDescent, Display, TEXT("M10 run: ending %d, end card %d, window %s, hull %.0f %%, balance %lld"), H->bEnding, H->bEndCardShown, *Descent::Ui::WindowName(Ui->GetWindow()).ToString(), Ui->Models().EndCard.HullPct, Ui->Models().EndCard.Balance); return H->bEnding && H->bEndCardShown && Ui->GetWindow() == EDescentWindow::EndCard; }); HudShot(S, TEXT("endcard")); S.DumpState(TEXT("end")); S.Quit(); }