From 8e6b18ed45d27f79d839a6ad6f4c109962f0dd67 Mon Sep 17 00:00:00 2001 From: Zizin13 <162662805+Zizin13@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:16:50 -0700 Subject: [PATCH 1/4] towers are visible in track editor --- CMakeLists.txt | 5 + TrackEditor/DisplaySettings.cpp | 19 +++ TrackEditor/DisplaySettings.h | 2 + TrackEditor/DisplaySettings.ui | 165 +++++++++++++------------ TrackEditor/DisplaySettingsFlags.h | 5 + TrackEditor/EditorOverlaySettings.cpp | 31 ++++- TrackEditor/EditorOverlaySettings.h | 7 +- TrackEditor/MainWindow.cpp | 10 ++ TrackEditor/TrackPreview.cpp | 15 +++ TrackEditor/TrackPreview.h | 2 + external/ROLLER | 2 +- tests/editor_overlay_settings_test.cpp | 50 +++++++- tests/test_e2_s1_roller_submodule.py | 2 +- tests/test_e3a_s4_helper_overlays.py | 13 +- tests/test_e7_s4_tower_display.py | 149 ++++++++++++++++++++++ 15 files changed, 382 insertions(+), 95 deletions(-) create mode 100644 tests/test_e7_s4_tower_display.py diff --git a/CMakeLists.txt b/CMakeLists.txt index fa945b5b..884cd371 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -200,6 +200,11 @@ if(BUILD_TESTING) COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_e6_s3_releases.py" -v) + add_test( + NAME trackeditor-e7-s4-tower-display-contract + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_e7_s4_tower_display.py" + -v) add_test( NAME trackeditor-graphics-settings-contract COMMAND "${Python3_EXECUTABLE}" diff --git a/TrackEditor/DisplaySettings.cpp b/TrackEditor/DisplaySettings.cpp index 48f24d53..606af46d 100644 --- a/TrackEditor/DisplaySettings.cpp +++ b/TrackEditor/DisplaySettings.cpp @@ -21,6 +21,7 @@ CDisplaySettings::CDisplaySettings(QWidget *pParent) ckTestCar->setChecked(true); ckAudio->setChecked(true); ckStunts->setChecked(true); + ckTowers->setChecked(true); cbTestCarType->addItem("F1WACK", (int)eWhipModel::CAR_F1WACK); cbTestCarType->addItem("XAUTO", (int)eWhipModel::CAR_XAUTO); @@ -78,6 +79,7 @@ CDisplaySettings::CDisplaySettings(QWidget *pParent) connect(ckSigns, &QCheckBox::toggled, this, &CDisplaySettings::UpdatePreviewSelection); connect(ckAudio, &QCheckBox::toggled, this, &CDisplaySettings::UpdatePreviewSelection); connect(ckStunts, &QCheckBox::toggled, this, &CDisplaySettings::UpdatePreviewSelection); + connect(ckTowers, &QCheckBox::toggled, this, &CDisplaySettings::UpdatePreviewSelection); connect(ckAnimateStunts, &QCheckBox::toggled, this, &CDisplaySettings::UpdatePreviewSelection); connect(ckTestCar, &QCheckBox::toggled, this, &CDisplaySettings::UpdatePreviewSelection); connect(cbTestCarType, SIGNAL(currentIndexChanged(int)), this, SLOT(UpdatePreviewSelection())); @@ -196,6 +198,23 @@ void CDisplaySettings::SetDisplaySettings(uint32 uiShowModels, eWhipModel carMod //------------------------------------------------------------------------------------------------- +uint32 CDisplaySettings::GetFeatureSettings() const +{ + uint32 uiShowFeatures = 0; + if (ckTowers->isChecked()) uiShowFeatures |= SHOW_FEATURE_TOWERS; + return uiShowFeatures; +} + +//------------------------------------------------------------------------------------------------- + +void CDisplaySettings::SetFeatureSettings(uint32 uiShowFeatures) +{ + BLOCK_SIG_AND_DO(ckTowers, setChecked(uiShowFeatures & SHOW_FEATURE_TOWERS)); + UpdatePreviewSelection(); +} + +//------------------------------------------------------------------------------------------------- + bool CDisplaySettings::GetAnimateStunts() const { return ckAnimateStunts->isChecked(); diff --git a/TrackEditor/DisplaySettings.h b/TrackEditor/DisplaySettings.h index ed5e462d..ca4c9732 100644 --- a/TrackEditor/DisplaySettings.h +++ b/TrackEditor/DisplaySettings.h @@ -17,6 +17,8 @@ class CDisplaySettings : public QWidget, private Ui::DisplaySettings uint32 GetDisplaySettings(eWhipModel &carModel, eShapeSection &aiLine, bool &bMillionPlus); void SetDisplaySettings(uint32 uiShowModels, eWhipModel carModel, eShapeSection aiLine, bool bMillionPlus); + uint32 GetFeatureSettings() const; + void SetFeatureSettings(uint32 uiShowFeatures); bool GetAnimateStunts() const; void SetAnimateStunts(bool bAnimate); bool GetAttachLast(); diff --git a/TrackEditor/DisplaySettings.ui b/TrackEditor/DisplaySettings.ui index cf9aea36..e34deca3 100644 --- a/TrackEditor/DisplaySettings.ui +++ b/TrackEditor/DisplaySettings.ui @@ -39,8 +39,8 @@ 0 0 - 280 - 787 + 263 + 869 @@ -64,7 +64,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -81,7 +81,7 @@ L Upper Outer Wall - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -115,7 +115,7 @@ Center - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -129,7 +129,7 @@ - Qt::Vertical + Qt::Orientation::Vertical @@ -163,7 +163,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -180,7 +180,7 @@ All - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -218,7 +218,7 @@ Roof - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -235,7 +235,7 @@ R Lower Outer Wall - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -249,7 +249,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -266,7 +266,7 @@ Left Shoulder - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -276,7 +276,7 @@ R Upper Outer Wall - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -297,7 +297,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -325,7 +325,6 @@ - 75 true @@ -333,7 +332,7 @@ Show - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -350,7 +349,7 @@ Position - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter @@ -374,7 +373,7 @@ Outer Wall Floor - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -384,7 +383,7 @@ Left Wall - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -408,7 +407,7 @@ Right Wall - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -432,25 +431,12 @@ L Lower Outer Wall - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - Qt::Horizontal - - - - 40 - 20 - - - - @@ -458,44 +444,6 @@ - - - - Stunt Markers - - - - - - - Animate Stunts - - - - - - - Center Line - - - true - - - - - - - Audio Markers - - - - - - - Attach Last - - - @@ -510,7 +458,7 @@ - + @@ -518,7 +466,7 @@ Camera Speed - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -547,7 +495,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -559,6 +507,67 @@ + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Attach Last + + + + + + + Center Line + + + true + + + + + + + Stunt Markers + + + + + + + Audio Markers + + + + + + + Animate Stunts + + + + + + + Towers + + + true + + + @@ -574,7 +583,7 @@ Right Shoulder - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -596,7 +605,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -751,7 +760,7 @@ - + Show Wireframe diff --git a/TrackEditor/DisplaySettingsFlags.h b/TrackEditor/DisplaySettingsFlags.h index 8cdcec36..b3b7a568 100644 --- a/TrackEditor/DisplaySettingsFlags.h +++ b/TrackEditor/DisplaySettingsFlags.h @@ -42,5 +42,10 @@ #define SHOW_CENTER_LINE 0x20000000 #define SHOW_REF_MODEL 0x40000000 #define SHOW_REF_WIRE_MODEL 0x80000000 + +// The original show_models word is full. New display features are persisted +// in a separate QSettings word named "show_features". These values are part +// of the saved editor profile too, so do not renumber or reuse them. +#define SHOW_FEATURE_TOWERS 0x00000001 //------------------------------------------------------------------------------------------------- #endif diff --git a/TrackEditor/EditorOverlaySettings.cpp b/TrackEditor/EditorOverlaySettings.cpp index 4390fb55..0bc2facb 100644 --- a/TrackEditor/EditorOverlaySettings.cpp +++ b/TrackEditor/EditorOverlaySettings.cpp @@ -52,7 +52,7 @@ const tSurfaceClassToggle g_aToggles[] = { //------------------------------------------------------------------------------------------------- struct tFeatureToggle { - uint32_t uiShowModelsBit; + uint32_t uiEditorBit; uint32_t uiOverlayFlag; }; @@ -122,12 +122,19 @@ const tFeatureToggle g_aFeatures[] = { { SHOW_STUNTS, ROLLER_ED_OVERLAY_SHOW_STUNT_MARKERS }, { SHOW_REF_MODEL, ROLLER_ED_OVERLAY_SHOW_REFERENCE_MESH } }; + +// The second persisted word starts here. Keeping a separate table makes it +// impossible for a new feature to alias one of the 32 legacy show_models bits. +const tFeatureToggle g_aDisplayFeatures[] = { + { SHOW_FEATURE_TOWERS, ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS } +}; } //------------------------------------------------------------------------------------------------- CEditorOverlaySettings::CEditorOverlaySettings() : m_uiShowModels(0) + , m_uiShowFeatures(0) , m_bHasShowModels(false) , m_iSelFrom(-1) , m_iSelTo(-1) @@ -197,6 +204,14 @@ void CEditorOverlaySettings::SetShowModels(uint32_t uiShowModels) //------------------------------------------------------------------------------------------------- +void CEditorOverlaySettings::SetShowFeatures(uint32_t uiShowFeatures) +{ + m_uiShowFeatures = uiShowFeatures; + Rebuild(); +} + +//------------------------------------------------------------------------------------------------- + void CEditorOverlaySettings::SetSelectionRange(int iSelFrom, int iSelTo) { m_iSelFrom = iSelFrom; @@ -235,9 +250,10 @@ void CEditorOverlaySettings::Rebuild() uiWireframeClassMask |= ROLLER_ED_OVERLAY_CLASS_BIT(Toggle.uiSurfaceClass); } - // Buildings and towers have never had a checkbox: the legacy editor always - // drew them. Keeping their class bits set preserves that, and leaves the - // door open for a checkbox later without another ABI change. + // Buildings and towers have no legacy surface-checkbox bits, so their class + // bits stay available. The core additionally requires SHOW_TOWER_MARKERS + // for a tower, so this class-mask compatibility does not override the new + // Towers feature checkbox. uiSurfaceClassMask |= ROLLER_ED_OVERLAY_CLASS_BIT(ROLLER_ED_SURFACE_CLASS_BUILDING) | ROLLER_ED_OVERLAY_CLASS_BIT(ROLLER_ED_SURFACE_CLASS_TOWER); @@ -251,7 +267,12 @@ void CEditorOverlaySettings::Rebuild() uiFlags |= ROLLER_ED_OVERLAY_SHOW_WIREFRAME; for (const tFeatureToggle &Feature : g_aFeatures) { - if ((m_uiShowModels & Feature.uiShowModelsBit) != 0) + if ((m_uiShowModels & Feature.uiEditorBit) != 0) + uiFlags |= Feature.uiOverlayFlag; + } + + for (const tFeatureToggle &Feature : g_aDisplayFeatures) { + if ((m_uiShowFeatures & Feature.uiEditorBit) != 0) uiFlags |= Feature.uiOverlayFlag; } diff --git a/TrackEditor/EditorOverlaySettings.h b/TrackEditor/EditorOverlaySettings.h index d6e9659c..e25b991c 100644 --- a/TrackEditor/EditorOverlaySettings.h +++ b/TrackEditor/EditorOverlaySettings.h @@ -7,7 +7,7 @@ #include //------------------------------------------------------------------------------------------------- -// Translates the editor's own SHOW_* display bitmask into the facade's +// Translates the editor's own persisted SHOW_* display words into the facade's // tEdOverlayState (E3A-S2). // // The two do not correspond one to one on purpose. The legacy mask names a @@ -28,6 +28,9 @@ class CEditorOverlaySettings // uiShowModels is the editor's DisplaySettings.h SHOW_* bitmask. void SetShowModels(uint32_t uiShowModels); + // uiShowFeatures is the extensible show_features word. It is intentionally + // separate because every bit in the legacy show_models word is allocated. + void SetShowFeatures(uint32_t uiShowFeatures); // iSelFrom/iSelTo are CTrackPreview's selection bounds; a negative bound // means nothing is selected. The car also stands on iSelFrom, which is // where the legacy editor drew it, so no separate call sets its chunk. @@ -36,6 +39,7 @@ class CEditorOverlaySettings void SetTestCar(eWhipModel carModel, eShapeSection aiLine, bool bMillionPlus); uint32_t GetShowModels() const { return m_uiShowModels; } + uint32_t GetShowFeatures() const { return m_uiShowFeatures; } const tEdOverlayState &GetOverlayState() const { return m_Overlay; } // ROLLER's own CAR_DESIGN_* index for an editor model, and the 0-based AI @@ -52,6 +56,7 @@ class CEditorOverlaySettings void Rebuild(); uint32_t m_uiShowModels; + uint32_t m_uiShowFeatures; // Until the window pushes its display settings, the preview shows what it // has always shown: every surface class solid, no wireframe. Deriving that // from a zero mask instead would blank the track for the first frames. diff --git a/TrackEditor/MainWindow.cpp b/TrackEditor/MainWindow.cpp index 2f6bb2b8..6f8d1b43 100644 --- a/TrackEditor/MainWindow.cpp +++ b/TrackEditor/MainWindow.cpp @@ -1106,6 +1106,8 @@ void CMainWindow::OnTabChanged(int iIndex) bool bMillionPlus; uint32 uiShowModels = p->m_pDisplaySettings->GetDisplaySettings(carModel, aiLine, bMillionPlus); p->m_previewAy[iIndex]->ShowModels(uiShowModels); + p->m_previewAy[iIndex]->ShowFeatures( + p->m_pDisplaySettings->GetFeatureSettings()); p->m_previewAy[iIndex]->SetAnimateStunts( p->m_pDisplaySettings->GetAnimateStunts()); p->m_previewAy[iIndex]->UpdateCar(carModel, aiLine, bMillionPlus); @@ -1230,6 +1232,8 @@ void CMainWindow::OnUpdatePreview() bool bMillionPlus; uint32 uiShowModels = p->m_pDisplaySettings->GetDisplaySettings(carModel, aiLine, bMillionPlus); GetCurrentPreview()->ShowModels(uiShowModels); + GetCurrentPreview()->ShowFeatures( + p->m_pDisplaySettings->GetFeatureSettings()); GetCurrentPreview()->SetAnimateStunts( p->m_pDisplaySettings->GetAnimateStunts()); GetCurrentPreview()->UpdateCar(carModel, aiLine, bMillionPlus); @@ -1369,11 +1373,14 @@ void CMainWindow::LoadSettings() eShapeSection aiLine; bool bMillionPlus; uint32 uiShowModels = p->m_pDisplaySettings->GetDisplaySettings(carModel, aiLine, bMillionPlus); + uint32 uiShowFeatures = p->m_pDisplaySettings->GetFeatureSettings(); bool bAnimateStunts = p->m_pDisplaySettings->GetAnimateStunts(); bool bAttachLast = p->m_pDisplaySettings->GetAttachLast(); int iCameraSpeed = (int)CEditorCameraController::GetMovementSpeed(); //load display settings uiShowModels = settings.value("show_models", uiShowModels).toUInt(); + uiShowFeatures = settings.value( + "show_features", SHOW_FEATURE_TOWERS).toUInt(); //E3A-S4 added the centre line after show_models was first written, so an //existing profile has its bit clear for no reason the user chose. Switch it //on once and remember having done so, then a later deliberate untick sticks. @@ -1389,6 +1396,7 @@ void CMainWindow::LoadSettings() bAttachLast = settings.value("attach_last", bAttachLast).toBool(); iCameraSpeed = settings.value("camera_speed", iCameraSpeed).toInt(); //apply display settings + p->m_pDisplaySettings->SetFeatureSettings(uiShowFeatures); p->m_pDisplaySettings->SetDisplaySettings(uiShowModels, carModel, aiLine, bMillionPlus); p->m_pDisplaySettings->SetAnimateStunts(bAnimateStunts); p->m_pDisplaySettings->SetAttachLast(bAttachLast); @@ -1465,6 +1473,8 @@ void CMainWindow::SaveSettings() settings.setValue("show_edit_audio", p->m_pEditAudioDockWidget->isVisible()); settings.setValue("show_edit_stunt", p->m_pEditStuntDockWidget->isVisible()); settings.setValue("show_models", p->m_pDisplaySettings->GetDisplaySettings(carModel, aiLine, bMillionPlus)); + settings.setValue("show_features", + p->m_pDisplaySettings->GetFeatureSettings()); settings.setValue("animate_stunts", p->m_pDisplaySettings->GetAnimateStunts()); settings.setValue("car_model", (int)carModel); diff --git a/TrackEditor/TrackPreview.cpp b/TrackEditor/TrackPreview.cpp index c506b013..04104dd1 100644 --- a/TrackEditor/TrackPreview.cpp +++ b/TrackEditor/TrackPreview.cpp @@ -57,6 +57,7 @@ CTrackPreview::CTrackPreview(QWidget *pParent, , m_iRefZ(0) , m_dRefScale(1.0) , m_uiShowModels(0) + , m_uiShowFeatures(0) , m_carModel(eWhipModel::CAR_XZIZIN) , m_carAILine(eShapeSection::AILINE1) , m_bMillionPlus(false) @@ -205,6 +206,20 @@ void CTrackPreview::ShowModels(uint32 uiShowModels) //------------------------------------------------------------------------------------------------- +void CTrackPreview::ShowFeatures(uint32 uiShowFeatures) +{ + if (m_uiShowFeatures == uiShowFeatures) + return; + m_uiShowFeatures = uiShowFeatures; + m_OverlaySettings.SetShowFeatures(uiShowFeatures); + // Feature visibility is overlay state only. The coalesced camera path + // renders it immediately without advancing the geometry/document epoch. + ScheduleCameraRender(); + update(); +} + +//------------------------------------------------------------------------------------------------- + void CTrackPreview::SetAnimateStunts(bool bAnimate) { if (m_bAnimateStunts == bAnimate) diff --git a/TrackEditor/TrackPreview.h b/TrackEditor/TrackPreview.h index 8416d70c..77dfb8e5 100644 --- a/TrackEditor/TrackPreview.h +++ b/TrackEditor/TrackPreview.h @@ -34,6 +34,7 @@ class CTrackPreview : public QWidget void DeleteEnvirFloor(); void UpdateTrack(); void ShowModels(uint32 uiShowModels); + void ShowFeatures(uint32 uiShowFeatures); void SetAnimateStunts(bool bAnimate); void UpdateCar(eWhipModel carModel, eShapeSection aiLine, bool bMillionPlus); void AttachLast(bool bAttachLast); @@ -125,6 +126,7 @@ class CTrackPreview : public QWidget CTrackPreviewPrivate *p; uint32 m_uiShowModels; + uint32 m_uiShowFeatures; eWhipModel m_carModel; eShapeSection m_carAILine; bool m_bMillionPlus; diff --git a/external/ROLLER b/external/ROLLER index 532a0270..aefd2491 160000 --- a/external/ROLLER +++ b/external/ROLLER @@ -1 +1 @@ -Subproject commit 532a02705a4113f0f8e2c74066735c1786c7375b +Subproject commit aefd24915960ccee2eb9bcce008f96e2f967ffb4 diff --git a/tests/editor_overlay_settings_test.cpp b/tests/editor_overlay_settings_test.cpp index 0744fdb7..92f592b0 100644 --- a/tests/editor_overlay_settings_test.cpp +++ b/tests/editor_overlay_settings_test.cpp @@ -54,7 +54,8 @@ bool ClassInMask(uint32_t uiMask, uint32_t uiSurfaceClass) return (uiMask & ROLLER_ED_OVERLAY_CLASS_BIT(uiSurfaceClass)) != 0; } -// Buildings and towers never had a checkbox and must stay visible. +// Buildings and towers have no legacy surface-checkbox bits. Tower drawing +// additionally requires the second-word marker flag after E7-S4. const uint32_t g_uiAlwaysVisible = ROLLER_ED_OVERLAY_CLASS_BIT(ROLLER_ED_SURFACE_CLASS_BUILDING) | ROLLER_ED_OVERLAY_CLASS_BIT(ROLLER_ED_SURFACE_CLASS_TOWER); @@ -106,7 +107,7 @@ void test_each_checkbox_selects_exactly_its_own_class() assert((Wire.uiFlags & ROLLER_ED_OVERLAY_SHOW_WIREFRAME) != 0); assert(Wire.uiWireframeClassMask == ROLLER_ED_OVERLAY_CLASS_BIT(Expected.uiSurfaceClass)); - // Only buildings and towers remain solid: no track class was checked. + // Only the non-legacy class bits remain: no track class was checked. assert(Wire.uiSurfaceClassMask == g_uiAlwaysVisible); } } @@ -155,15 +156,17 @@ void test_signs_follow_their_single_checkbox() ROLLER_ED_SURFACE_CLASS_SIGN)); } -void test_buildings_and_towers_are_always_drawn() +void test_building_and_tower_class_bits_remain_available() { CEditorOverlaySettings Settings; Settings.SetShowModels(0); const tEdOverlayState &State = Settings.GetOverlayState(); assert(State.uiSurfaceClassMask == g_uiAlwaysVisible); - // Even with every box cleared, the master stays on for them. + // Even with every legacy surface box cleared, the class mask stays valid. + // A tower still needs ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS to be accepted. assert((State.uiFlags & ROLLER_ED_OVERLAY_SHOW_SURFACES) != 0); + assert((State.uiFlags & ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS) == 0); assert((State.uiFlags & ROLLER_ED_OVERLAY_SHOW_WIREFRAME) == 0); } @@ -194,6 +197,39 @@ void test_feature_toggles_map_to_overlay_flags() } } +void test_the_second_feature_word_controls_tower_markers() +{ + static_assert(SHOW_FEATURE_TOWERS == 0x00000001, + "persisted show_features values must not be renumbered"); + + CEditorOverlaySettings Settings; + Settings.SetShowModels(SHOW_CENTER_SURF_MODEL | SHOW_AUDIO); + assert((Settings.GetOverlayState().uiFlags + & ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS) == 0); + + Settings.SetShowFeatures(SHOW_FEATURE_TOWERS); + assert(Settings.GetShowFeatures() == SHOW_FEATURE_TOWERS); + assert((Settings.GetOverlayState().uiFlags + & ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS) != 0); + // The second word changes only its feature: it cannot overwrite a legacy + // preference while both words are translated into one facade request. + assert((Settings.GetOverlayState().uiFlags + & ROLLER_ED_OVERLAY_SHOW_AUDIO_MARKERS) != 0); + assert(ClassInMask(Settings.GetOverlayState().uiSurfaceClassMask, + ROLLER_ED_SURFACE_CLASS_CENTER)); + + Settings.SetShowFeatures(0); + assert((Settings.GetOverlayState().uiFlags + & ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS) == 0); + assert((Settings.GetOverlayState().uiFlags + & ROLLER_ED_OVERLAY_SHOW_AUDIO_MARKERS) != 0); + + // Reserved bits in the extensible word are inert until explicitly mapped. + Settings.SetShowFeatures(0x80000000u); + assert((Settings.GetOverlayState().uiFlags + & ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS) == 0); +} + void test_no_undefined_flag_or_class_bit_is_ever_published() { // The facade refuses an unknown flag or class bit outright, so a mask this @@ -222,7 +258,8 @@ void test_no_undefined_flag_or_class_bit_is_ever_published() | ROLLER_ED_OVERLAY_SHOW_TEST_CAR | ROLLER_ED_OVERLAY_SHOW_REFERENCE_MESH | ROLLER_ED_OVERLAY_TEST_CAR_MILLION_PLUS - | ROLLER_ED_OVERLAY_TEST_CAR_ADVANCED)) == 0); + | ROLLER_ED_OVERLAY_TEST_CAR_ADVANCED + | ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS)) == 0); } } @@ -552,8 +589,9 @@ int main() test_each_checkbox_selects_exactly_its_own_class(); test_surface_and_wireframe_stay_independent(); test_signs_follow_their_single_checkbox(); - test_buildings_and_towers_are_always_drawn(); + test_building_and_tower_class_bits_remain_available(); test_feature_toggles_map_to_overlay_flags(); + test_the_second_feature_word_controls_tower_markers(); test_no_undefined_flag_or_class_bit_is_ever_published(); test_selection_range_uses_the_sentinel_for_no_selection(); test_every_car_model_maps_to_a_design_in_range(); diff --git a/tests/test_e2_s1_roller_submodule.py b/tests/test_e2_s1_roller_submodule.py index 02d3b6d6..9220235b 100644 --- a/tests/test_e2_s1_roller_submodule.py +++ b/tests/test_e2_s1_roller_submodule.py @@ -10,7 +10,7 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[1] ROLLER_ROOT = REPOSITORY_ROOT / "external" / "ROLLER" -EXPECTED_ROLLER_COMMIT = "532a02705a4113f0f8e2c74066735c1786c7375b" +EXPECTED_ROLLER_COMMIT = "aefd24915960ccee2eb9bcce008f96e2f967ffb4" def run_git(*arguments: str, cwd: Path = REPOSITORY_ROOT) -> subprocess.CompletedProcess: diff --git a/tests/test_e3a_s4_helper_overlays.py b/tests/test_e3a_s4_helper_overlays.py index cacf7334..70d9a2c3 100644 --- a/tests/test_e3a_s4_helper_overlays.py +++ b/tests/test_e3a_s4_helper_overlays.py @@ -27,10 +27,17 @@ def test_the_new_bit_does_not_collide_with_an_existing_one(self) -> None: values = {} for line in flags.splitlines(): parts = line.split() - if len(parts) == 3 and parts[0] == "#define" and parts[1].startswith("SHOW_"): + if ( + len(parts) == 3 + and parts[0] == "#define" + and parts[1].startswith("SHOW_") + and not parts[1].startswith("SHOW_FEATURE_") + ): values.setdefault(int(parts[2], 16), []).append(parts[1]) - # The mask is persisted in QSettings, so a collision would silently - # tie two checkboxes together in every existing user's settings. + # The legacy show_models word is persisted in QSettings, so a + # collision within that word would silently tie two checkboxes + # together. E7-S4 starts the separate SHOW_FEATURE_/show_features + # word, whose values intentionally begin again at bit zero. collisions = {v: n for v, n in values.items() if len(n) > 1} self.assertEqual(collisions, {}) self.assertIn("SHOW_CENTER_LINE", values.get(0x20000000, [])) diff --git a/tests/test_e7_s4_tower_display.py b/tests/test_e7_s4_tower_display.py new file mode 100644 index 00000000..526a2273 --- /dev/null +++ b/tests/test_e7_s4_tower_display.py @@ -0,0 +1,149 @@ +import re +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +EDITOR = ROOT / "TrackEditor" + + +def source(name: str) -> str: + return (EDITOR / name).read_text(encoding="utf-8") + + +def function_body(text: str, signature: str) -> str: + start = text.index(signature) + brace = text.index("{", start) + depth = 0 + for position in range(brace, len(text)): + if text[position] == "{": + depth += 1 + elif text[position] == "}": + depth -= 1 + if depth == 0: + return text[start : position + 1] + raise AssertionError(f"unterminated function: {signature}") + + +class PinnedCoreTests(unittest.TestCase): + def test_the_pin_exposes_the_e7_s3_tower_marker_flag(self) -> None: + header = ( + ROOT / "external" / "ROLLER" / "PROJECTS" / "ROLLER" / "editor_api.h" + ).read_text(encoding="utf-8") + self.assertRegex( + header, + r"ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS\s*=\s*1u\s*<<\s*12", + ) + + +class FeatureWordTests(unittest.TestCase): + def test_towers_use_the_first_bit_of_a_second_persisted_word(self) -> None: + flags = source("DisplaySettingsFlags.h") + self.assertIn("#define SHOW_FEATURE_TOWERS 0x00000001", flags) + self.assertIn('QSettings word named "show_features"', flags) + + # The retired environment bit remains retired; Towers must not consume + # a supposedly free bit in the already persisted legacy word. + self.assertIn("#define SHOW_ENVIRONMENT_RETIRED 0x01000000", flags) + self.assertNotRegex( + flags, + r"SHOW_FEATURE_TOWERS\s+0x01000000", + ) + + def test_the_translator_maps_only_the_new_word_to_the_facade_flag(self) -> None: + overlay = source("EditorOverlaySettings.cpp") + table_start = overlay.index("g_aDisplayFeatures[]") + table = overlay[table_start : overlay.index("};", table_start)] + self.assertIn("SHOW_FEATURE_TOWERS", table) + self.assertIn("ROLLER_ED_OVERLAY_SHOW_TOWER_MARKERS", table) + + legacy_start = overlay.index("g_aFeatures[]") + legacy_table = overlay[legacy_start : overlay.index("};", legacy_start)] + self.assertNotIn("SHOW_FEATURE_TOWERS", legacy_table) + + +class DisplaySettingsTests(unittest.TestCase): + def test_the_default_on_checkbox_sits_beside_stunt_markers(self) -> None: + tree = ET.parse(EDITOR / "DisplaySettings.ui") + positions: dict[str, tuple[str | None, str | None]] = {} + towers = None + for item in tree.iter("item"): + widget = item.find("widget") + if widget is None: + continue + name = widget.attrib.get("name") + if name in ("ckStunts", "ckTowers"): + positions[name] = (item.attrib.get("row"), item.attrib.get("column")) + if name == "ckTowers": + towers = widget + + self.assertEqual(positions["ckTowers"][0], positions["ckStunts"][0]) + self.assertNotEqual(positions["ckTowers"][1], positions["ckStunts"][1]) + self.assertIsNotNone(towers) + self.assertEqual(towers.findtext("property[@name='text']/string"), "Towers") + self.assertEqual(towers.findtext("property[@name='checked']/bool"), "true") + + def test_the_checkbox_is_read_written_defaulted_and_live(self) -> None: + display = source("DisplaySettings.cpp") + for contract in ( + "ckTowers->setChecked(true)", + "ckTowers->isChecked()", + "SHOW_FEATURE_TOWERS", + "BLOCK_SIG_AND_DO(ckTowers", + "connect(ckTowers, &QCheckBox::toggled, this, " + "&CDisplaySettings::UpdatePreviewSelection);", + ): + self.assertIn(contract, display) + + +class PersistenceTests(unittest.TestCase): + def test_the_new_word_is_default_on_and_persists_without_migration(self) -> None: + window = source("MainWindow.cpp") + self.assertRegex( + window, + r'settings\.value\(\s*"show_features",\s*SHOW_FEATURE_TOWERS\s*\)' + r"\.toUInt\(\)", + ) + self.assertRegex( + window, + r'settings\.setValue\(\s*"show_features",\s*' + r"p->m_pDisplaySettings->GetFeatureSettings\(\)\s*\)", + ) + self.assertNotIn("tower_default_applied", window) + self.assertNotIn("towers_default_applied", window) + + def test_the_legacy_show_models_value_remains_separate(self) -> None: + window = source("MainWindow.cpp") + show_models_lines = [ + line for line in window.splitlines() if '"show_models"' in line + ] + self.assertGreaterEqual(len(show_models_lines), 2) + self.assertTrue(all("SHOW_FEATURE_TOWERS" not in line for line in show_models_lines)) + + +class LivePreviewTests(unittest.TestCase): + def test_tabs_and_checkbox_updates_push_the_feature_word(self) -> None: + window = source("MainWindow.cpp") + self.assertGreaterEqual(window.count("->ShowFeatures("), 2) + self.assertGreaterEqual(window.count("GetFeatureSettings()"), 4) + + def test_toggling_towers_uses_the_render_only_path(self) -> None: + preview = source("TrackPreview.cpp") + body = function_body(preview, "void CTrackPreview::ShowFeatures(") + self.assertIn("m_OverlaySettings.SetShowFeatures", body) + self.assertIn("ScheduleCameraRender();", body) + self.assertIn("update();", body) + for forbidden in ( + "MarkDocumentEdited", + "SaveHistory", + "QueueEditedTrackReload", + "QueueLoadAndRender", + "GetTrackData", + "m_uiRevision", + ): + self.assertNotIn(forbidden, body) + + +if __name__ == "__main__": + unittest.main() From f0487cce30c354b32bdd371e0a59f41d472a129a Mon Sep 17 00:00:00 2001 From: Zizin13 <162662805+Zizin13@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:35:16 -0700 Subject: [PATCH 2/4] edit tower dock widget --- CMakeLists.txt | 18 +++ TrackEditor/CMakeLists.txt | 5 + TrackEditor/EditTowerWidget.cpp | 255 ++++++++++++++++++++++++++++++ TrackEditor/EditTowerWidget.h | 30 ++++ TrackEditor/EditTowerWidget.ui | 176 +++++++++++++++++++++ TrackEditor/EditorTowerModel.cpp | 248 +++++++++++++++++++++++++++++ TrackEditor/EditorTowerModel.h | 56 +++++++ TrackEditor/MainWindow.cpp | 21 +++ tests/editor_tower_model_test.cpp | 196 +++++++++++++++++++++++ tests/test_e7_s4_tower_display.py | 28 ++-- tests/test_e7_s5_edit_towers.py | 213 +++++++++++++++++++++++++ 11 files changed, 1232 insertions(+), 14 deletions(-) create mode 100644 TrackEditor/EditTowerWidget.cpp create mode 100644 TrackEditor/EditTowerWidget.h create mode 100644 TrackEditor/EditTowerWidget.ui create mode 100644 TrackEditor/EditorTowerModel.cpp create mode 100644 TrackEditor/EditorTowerModel.h create mode 100644 tests/editor_tower_model_test.cpp create mode 100644 tests/test_e7_s5_edit_towers.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 884cd371..222c5fa3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,6 +53,18 @@ if(BUILD_TESTING) add_test(NAME trackeditor-e3-s5b-track-assets COMMAND trackeditor-e3-s5b-track-assets-test) + add_executable(trackeditor-e7-s5-tower-model-test + tests/editor_tower_model_test.cpp + TrackEditor/EditorTowerModel.cpp + TrackEditor/EditorTowerModel.h) + target_compile_features(trackeditor-e7-s5-tower-model-test PRIVATE cxx_std_17) + target_include_directories(trackeditor-e7-s5-tower-model-test PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/TrackEditor") + target_link_libraries(trackeditor-e7-s5-tower-model-test PRIVATE + TrackEditor::track-model) + add_test(NAME trackeditor-e7-s5-tower-model + COMMAND trackeditor-e7-s5-tower-model-test) + find_package(Python3 COMPONENTS Interpreter QUIET) if(Python3_Interpreter_FOUND) add_test( @@ -205,6 +217,11 @@ if(BUILD_TESTING) COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_e7_s4_tower_display.py" -v) + add_test( + NAME trackeditor-e7-s5-edit-towers-contract + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_e7_s5_edit_towers.py" + -v) add_test( NAME trackeditor-graphics-settings-contract COMMAND "${Python3_EXECUTABLE}" @@ -395,6 +412,7 @@ if(BUILD_TESTING) foreach(trackeditor_test_target IN ITEMS trackeditor-e3-s5a-track-model-test trackeditor-e3-s5b-track-assets-test + trackeditor-e7-s5-tower-model-test trackeditor-e3-s1-frame-delivery-test trackeditor-e3-s1-render-service-test trackeditor-e3a-s2-overlay-settings-test diff --git a/TrackEditor/CMakeLists.txt b/TrackEditor/CMakeLists.txt index 719323c5..3f037a56 100644 --- a/TrackEditor/CMakeLists.txt +++ b/TrackEditor/CMakeLists.txt @@ -29,6 +29,9 @@ set(trackeditor_sources EditStuntWidget.cpp EditStuntWidget.h EditStuntWidget.ui + EditTowerWidget.cpp + EditTowerWidget.h + EditTowerWidget.ui EditSurfaceDialog.cpp EditSurfaceDialog.h EditSurfaceDialog.ui @@ -52,6 +55,8 @@ set(trackeditor_sources EditorOverlaySettings.h EditorReferenceMesh.cpp EditorReferenceMesh.h + EditorTowerModel.cpp + EditorTowerModel.h EditorRenderQueue.cpp EditorRenderQueue.h EditorRenderService.cpp diff --git a/TrackEditor/EditTowerWidget.cpp b/TrackEditor/EditTowerWidget.cpp new file mode 100644 index 00000000..f9896eea --- /dev/null +++ b/TrackEditor/EditTowerWidget.cpp @@ -0,0 +1,255 @@ +#include "EditTowerWidget.h" + +#include "EditorTowerModel.h" +#include "MainWindow.h" +#include "QtHelpers.h" +#include "Track.h" + +#include +#include + +//------------------------------------------------------------------------------------------------- + +CEditTowerWidget::CEditTowerWidget(QWidget *pParent) + : QWidget(pParent) +{ + setupUi(this); + + cbMode->addItem("Static", static_cast(eEditorTowerMode::STATIC)); + cbMode->addItem("Follow near (25%)", + static_cast(eEditorTowerMode::FOLLOW_NEAR)); + cbMode->addItem("Follow at distance", + static_cast(eEditorTowerMode::FOLLOW_AT_DISTANCE)); + cbMode->addItem("Track surface, 2 back", + static_cast(eEditorTowerMode::TRACK_SURFACE_TWO_BACK)); + cbMode->addItem("Overhead follow", + static_cast(eEditorTowerMode::OVERHEAD_FOLLOW)); + + cbZoom->addItem("Unchanged", 0); + cbZoom->addItem("1 - VIEWDIST 120", 1); + cbZoom->addItem("2 - VIEWDIST 75", 2); + cbZoom->addItem("3 - VIEWDIST 500", 3); + cbZoom->addItem("4 - VIEWDIST 750", 4); + + leRawType->setValidator(new QIntValidator( + CEditorTowerModel::TOWER_TYPE_BASE, + std::numeric_limits::max(), leRawType)); + lblSignDisabled->setStyleSheet("QLabel { color : red; }"); + lblBudget->setStyleSheet(""); + lblSignDisabled->hide(); + lblRawPreserved->hide(); + lblOffsetScale->hide(); + + connect(g_pMainWindow, &CMainWindow::UpdateGeometrySelectionSig, + this, &CEditTowerWidget::UpdateGeometrySelection); + connect(pbTower, &QPushButton::clicked, + this, &CEditTowerWidget::TowerClicked); + connect(cbMode, SIGNAL(currentIndexChanged(int)), + this, SLOT(ModeChanged(int))); + connect(cbZoom, SIGNAL(currentIndexChanged(int)), + this, SLOT(ZoomChanged(int))); + connect(sbHOffset, SIGNAL(valueChanged(int)), + this, SLOT(HOffsetChanged(int))); + connect(sbVOffset, SIGNAL(valueChanged(int)), + this, SLOT(VOffsetChanged(int))); + connect(leRawType, &QLineEdit::textChanged, + this, &CEditTowerWidget::RawTypeChanged); +} + +//------------------------------------------------------------------------------------------------- + +CEditTowerWidget::~CEditTowerWidget() +{ +} + +//------------------------------------------------------------------------------------------------- + +bool CEditTowerWidget::GetSelection(CTrack *&pTrackOut, + int &iFromOut, int &iToOut) const +{ + pTrackOut = g_pMainWindow->GetCurrentTrack(); + iFromOut = g_pMainWindow->GetSelFrom(); + iToOut = g_pMainWindow->GetSelTo(); + return pTrackOut && iFromOut >= 0 && iToOut >= iFromOut + && iToOut < static_cast(pTrackOut->m_chunkAy.size()); +} + +//------------------------------------------------------------------------------------------------- + +void CEditTowerWidget::UpdateGeometrySelection(int iFrom, int iTo) +{ + (void)iTo; + CTrack *pTrack = g_pMainWindow->GetCurrentTrack(); + if (!pTrack || iFrom < 0 || iFrom >= static_cast(pTrack->m_chunkAy.size())) + return; + + const tGeometryChunk &Chunk = pTrack->m_chunkAy[iFrom]; + const bool bHasTower = CEditorTowerModel::IsTower(Chunk.iSignType); + const bool bHasSign = CEditorTowerModel::IsSign(Chunk.iSignType); + const int iTowerCount = CEditorTowerModel::CountTowers(pTrack->m_chunkAy); + const eEditorTowerMode mode = bHasTower + ? CEditorTowerModel::DecodeMode(Chunk.iSignType) + : eEditorTowerMode::STATIC; + const int iZoom = bHasTower + ? CEditorTowerModel::DecodeZoom(Chunk.iSignType) : 0; + + BLOCK_SIG_AND_DO(cbMode, setCurrentIndex( + cbMode->findData(static_cast(mode)))); + BLOCK_SIG_AND_DO(cbZoom, setCurrentIndex(cbZoom->findData(iZoom))); + BLOCK_SIG_AND_DO(sbHOffset, setValue(Chunk.iSignHorizOffset)); + BLOCK_SIG_AND_DO(sbVOffset, setValue(Chunk.iSignVertOffset)); + BLOCK_SIG_AND_DO(leRawType, setText( + bHasTower ? QString::number(Chunk.iSignType) : QString())); + + const bool bCanAdd = !bHasSign && !bHasTower + && iTowerCount < CEditorTowerModel::TOWER_LIMIT; + pbTower->setText(bHasTower ? "Delete Tower" : "Add Tower"); + pbTower->setEnabled(bHasTower || bCanAdd); + + cbMode->setEnabled(bHasTower); + cbZoom->setEnabled(bHasTower); + leRawType->setEnabled(bHasTower); + sbHOffset->setEnabled(bHasTower + && CEditorTowerModel::UsesHorizontalOffset(mode)); + sbVOffset->setEnabled(bHasTower + && CEditorTowerModel::UsesVerticalOffset(mode)); + lblMode->setEnabled(bHasTower); + lblZoom->setEnabled(bHasTower); + lblRawType->setEnabled(bHasTower); + lblHOffset->setEnabled(bHasTower); + lblVOffset->setEnabled(bHasTower); + + lblSignDisabled->setVisible(bHasSign); + // A disabled parent label is gray, so explicitly keep the red reason live. + lblSignDisabled->setEnabled(true); + + QString sBudget = QString("%1 of %2") + .arg(iTowerCount).arg(CEditorTowerModel::TOWER_LIMIT); + if (iTowerCount >= CEditorTowerModel::TOWER_LIMIT) + sBudget += " - tower limit reached"; + lblBudget->setText(sBudget); + lblBudget->setStyleSheet(iTowerCount >= CEditorTowerModel::TOWER_LIMIT + ? "QLabel { color : red; }" : ""); + + const bool bOverhead = bHasTower + && mode == eEditorTowerMode::OVERHEAD_FOLLOW; + const bool bTrackSurface = bHasTower + && mode == eEditorTowerMode::TRACK_SURFACE_TWO_BACK; + lblOffsetScale->setText(bTrackSurface + ? "Track-surface mode ignores both offsets." + : (bOverhead + ? "Overhead mode uses vertical offset x128 at runtime." + : "Offsets use x32 track units at runtime.")); + lblOffsetScale->setVisible(bHasTower); + lblRawPreserved->setVisible(bHasTower + && !CEditorTowerModel::IsCanonical(Chunk.iSignType)); +} + +//------------------------------------------------------------------------------------------------- + +void CEditTowerWidget::CommitEdit(int iChanged, const QString &sDescription) +{ + if (iChanged == 0) + return; + g_pMainWindow->SaveHistory(sDescription); + g_pMainWindow->UpdateWindow(); +} + +//------------------------------------------------------------------------------------------------- + +void CEditTowerWidget::TowerClicked() +{ + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (!GetSelection(pTrack, iFrom, iTo)) + return; + + // The displayed chunk owns the button's lifecycle. A sign is a defensive + // no-op even if this slot is invoked directly while the button is disabled. + if (CEditorTowerModel::IsSign(pTrack->m_chunkAy[iFrom].iSignType)) + return; + + const bool bDelete = CEditorTowerModel::IsTower( + pTrack->m_chunkAy[iFrom].iSignType); + const int iChanged = bDelete + ? CEditorTowerModel::DeleteTowers(pTrack->m_chunkAy, iFrom, iTo) + : CEditorTowerModel::AddTowers(pTrack->m_chunkAy, iFrom, iTo); + CommitEdit(iChanged, bDelete ? "Removed tower" : "Added tower"); +} + +//------------------------------------------------------------------------------------------------- + +void CEditTowerWidget::ModeChanged(int iIndex) +{ + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (iIndex < 0 || !GetSelection(pTrack, iFrom, iTo)) + return; + const eEditorTowerMode mode = static_cast( + cbMode->itemData(iIndex).toInt()); + CommitEdit(CEditorTowerModel::SetMode( + pTrack->m_chunkAy, iFrom, iTo, mode), "Changed tower camera mode"); +} + +//------------------------------------------------------------------------------------------------- + +void CEditTowerWidget::ZoomChanged(int iIndex) +{ + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (iIndex < 0 || !GetSelection(pTrack, iFrom, iTo)) + return; + CommitEdit(CEditorTowerModel::SetZoom( + pTrack->m_chunkAy, iFrom, iTo, cbZoom->itemData(iIndex).toInt()), + "Changed tower zoom"); +} + +//------------------------------------------------------------------------------------------------- + +void CEditTowerWidget::HOffsetChanged(int iValue) +{ + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (!GetSelection(pTrack, iFrom, iTo)) + return; + CommitEdit(CEditorTowerModel::SetHorizontalOffset( + pTrack->m_chunkAy, iFrom, iTo, iValue), + "Changed tower horizontal offset"); +} + +//------------------------------------------------------------------------------------------------- + +void CEditTowerWidget::VOffsetChanged(int iValue) +{ + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (!GetSelection(pTrack, iFrom, iTo)) + return; + CommitEdit(CEditorTowerModel::SetVerticalOffset( + pTrack->m_chunkAy, iFrom, iTo, iValue), + "Changed tower vertical offset"); +} + +//------------------------------------------------------------------------------------------------- + +void CEditTowerWidget::RawTypeChanged(const QString &sText) +{ + bool bOk = false; + const int iSignType = sText.toInt(&bOk); + if (!bOk || !CEditorTowerModel::IsTower(iSignType)) + return; + + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (!GetSelection(pTrack, iFrom, iTo)) + return; + CommitEdit(CEditorTowerModel::SetRawType( + pTrack->m_chunkAy, iFrom, iTo, iSignType), + "Changed raw tower type"); +} diff --git a/TrackEditor/EditTowerWidget.h b/TrackEditor/EditTowerWidget.h new file mode 100644 index 00000000..c38d595d --- /dev/null +++ b/TrackEditor/EditTowerWidget.h @@ -0,0 +1,30 @@ +#ifndef TRACKEDITOR_EDITTOWERWIDGET_H +#define TRACKEDITOR_EDITTOWERWIDGET_H + +#include "ui_EditTowerWidget.h" + +class CTrack; + +class CEditTowerWidget : public QWidget, private Ui::EditTowerWidget +{ + Q_OBJECT + +public: + explicit CEditTowerWidget(QWidget *pParent); + ~CEditTowerWidget(); + +protected slots: + void UpdateGeometrySelection(int iFrom, int iTo); + void TowerClicked(); + void ModeChanged(int iIndex); + void ZoomChanged(int iIndex); + void HOffsetChanged(int iValue); + void VOffsetChanged(int iValue); + void RawTypeChanged(const QString &sText); + +private: + bool GetSelection(CTrack *&pTrackOut, int &iFromOut, int &iToOut) const; + void CommitEdit(int iChanged, const QString &sDescription); +}; + +#endif diff --git a/TrackEditor/EditTowerWidget.ui b/TrackEditor/EditTowerWidget.ui new file mode 100644 index 00000000..a4897b30 --- /dev/null +++ b/TrackEditor/EditTowerWidget.ui @@ -0,0 +1,176 @@ + + + EditTowerWidget + + + + 0 + 0 + 360 + 300 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + + + 0 + 0 + 358 + 298 + + + + + + + Add Tower + + + + + + + 0 of 32 + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Sign exists on this chunk + + + + + + + Camera Mode + + + + + + + + + + Zoom + + + + + + + + + + Horizontal Offset + + + + + + + -65535 + + + 65535 + + + + + + + Vertical Offset + + + + + + + -65535 + + + 65535 + + + + + + + Offsets use x32 track units at runtime. + + + true + + + + + + + Raw iSignType + + + + + + + + + + Non-canonical value preserved until Mode or Zoom changes. + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + diff --git a/TrackEditor/EditorTowerModel.cpp b/TrackEditor/EditorTowerModel.cpp new file mode 100644 index 00000000..8db7c39a --- /dev/null +++ b/TrackEditor/EditorTowerModel.cpp @@ -0,0 +1,248 @@ +#include "EditorTowerModel.h" + +#include + +//------------------------------------------------------------------------------------------------- + +bool CEditorTowerModel::IsTower(int iSignType) +{ + return iSignType >= TOWER_TYPE_BASE; +} + +//------------------------------------------------------------------------------------------------- + +bool CEditorTowerModel::IsSign(int iSignType) +{ + return iSignType >= 0 && iSignType < TOWER_TYPE_BASE; +} + +//------------------------------------------------------------------------------------------------- + +eEditorTowerMode CEditorTowerModel::DecodeMode(int iSignType) +{ + switch (iSignType & 0xFF0F) { + case 0x101: return eEditorTowerMode::TRACK_SURFACE_TWO_BACK; + case 0x103: return eEditorTowerMode::FOLLOW_NEAR; + case 0x104: return eEditorTowerMode::OVERHEAD_FOLLOW; + case 0x105: return eEditorTowerMode::FOLLOW_AT_DISTANCE; + default: return eEditorTowerMode::STATIC; + } +} + +//------------------------------------------------------------------------------------------------- + +int CEditorTowerModel::DecodeZoom(int iSignType) +{ + if (!IsTower(iSignType)) + return 0; + const int iZoom = (iSignType - TOWER_TYPE_BASE) / 16; + return iZoom >= 1 && iZoom <= MAX_CANONICAL_ZOOM ? iZoom : 0; +} + +//------------------------------------------------------------------------------------------------- + +int CEditorTowerModel::ModeNibble(eEditorTowerMode mode) +{ + switch (mode) { + case eEditorTowerMode::TRACK_SURFACE_TWO_BACK: return 1; + case eEditorTowerMode::FOLLOW_NEAR: return 3; + case eEditorTowerMode::OVERHEAD_FOLLOW: return 4; + case eEditorTowerMode::FOLLOW_AT_DISTANCE: return 5; + case eEditorTowerMode::STATIC: break; + } + return 0; +} + +//------------------------------------------------------------------------------------------------- + +int CEditorTowerModel::Encode(eEditorTowerMode mode, int iZoom) +{ + const int iCanonicalZoom = std::clamp(iZoom, 0, MAX_CANONICAL_ZOOM); + return TOWER_TYPE_BASE + 16 * iCanonicalZoom + ModeNibble(mode); +} + +//------------------------------------------------------------------------------------------------- + +bool CEditorTowerModel::IsCanonical(int iSignType) +{ + return IsTower(iSignType) + && Encode(DecodeMode(iSignType), DecodeZoom(iSignType)) == iSignType; +} + +//------------------------------------------------------------------------------------------------- + +bool CEditorTowerModel::UsesHorizontalOffset(eEditorTowerMode mode) +{ + return mode != eEditorTowerMode::TRACK_SURFACE_TWO_BACK + && mode != eEditorTowerMode::OVERHEAD_FOLLOW; +} + +//------------------------------------------------------------------------------------------------- + +bool CEditorTowerModel::UsesVerticalOffset(eEditorTowerMode mode) +{ + return mode != eEditorTowerMode::TRACK_SURFACE_TWO_BACK; +} + +//------------------------------------------------------------------------------------------------- + +int CEditorTowerModel::VerticalOffsetScale(eEditorTowerMode mode) +{ + return mode == eEditorTowerMode::OVERHEAD_FOLLOW ? 128 : 32; +} + +//------------------------------------------------------------------------------------------------- + +bool CEditorTowerModel::IsValidRange(const CChunkAy &chunkAy, + int iFrom, int iTo) +{ + return iFrom >= 0 && iTo >= iFrom + && iTo < static_cast(chunkAy.size()); +} + +//------------------------------------------------------------------------------------------------- + +int CEditorTowerModel::CountTowers(const CChunkAy &chunkAy) +{ + return static_cast(std::count_if( + chunkAy.begin(), chunkAy.end(), [](const tGeometryChunk &Chunk) { + return IsTower(Chunk.iSignType); + })); +} + +//------------------------------------------------------------------------------------------------- + +int CEditorTowerModel::AddTowers(CChunkAy &chunkAy, int iFrom, int iTo) +{ + if (!IsValidRange(chunkAy, iFrom, iTo)) + return 0; + + int iTowerCount = CountTowers(chunkAy); + int iChanged = 0; + for (int i = iFrom; i <= iTo && iTowerCount < TOWER_LIMIT; ++i) { + // -1 is the only empty sign column. Existing signs and towers are both + // owned by their respective dock and are never silently replaced. + if (chunkAy[i].iSignType != -1) + continue; + chunkAy[i].iSignType = Encode(eEditorTowerMode::STATIC, 0); + ++iTowerCount; + ++iChanged; + } + return iChanged; +} + +//------------------------------------------------------------------------------------------------- + +int CEditorTowerModel::DeleteTowers(CChunkAy &chunkAy, int iFrom, int iTo) +{ + if (!IsValidRange(chunkAy, iFrom, iTo)) + return 0; + + int iChanged = 0; + for (int i = iFrom; i <= iTo; ++i) { + if (!IsTower(chunkAy[i].iSignType)) + continue; + chunkAy[i].iSignType = -1; + ++iChanged; + } + return iChanged; +} + +//------------------------------------------------------------------------------------------------- + +int CEditorTowerModel::SetMode(CChunkAy &chunkAy, int iFrom, int iTo, + eEditorTowerMode mode) +{ + if (!IsValidRange(chunkAy, iFrom, iTo)) + return 0; + + int iChanged = 0; + for (int i = iFrom; i <= iTo; ++i) { + if (!IsTower(chunkAy[i].iSignType)) + continue; + const int iNewType = Encode(mode, DecodeZoom(chunkAy[i].iSignType)); + if (chunkAy[i].iSignType == iNewType) + continue; + chunkAy[i].iSignType = iNewType; + ++iChanged; + } + return iChanged; +} + +//------------------------------------------------------------------------------------------------- + +int CEditorTowerModel::SetZoom(CChunkAy &chunkAy, int iFrom, int iTo, + int iZoom) +{ + if (!IsValidRange(chunkAy, iFrom, iTo)) + return 0; + + int iChanged = 0; + for (int i = iFrom; i <= iTo; ++i) { + if (!IsTower(chunkAy[i].iSignType)) + continue; + const int iNewType = Encode(DecodeMode(chunkAy[i].iSignType), iZoom); + if (chunkAy[i].iSignType == iNewType) + continue; + chunkAy[i].iSignType = iNewType; + ++iChanged; + } + return iChanged; +} + +//------------------------------------------------------------------------------------------------- + +int CEditorTowerModel::SetHorizontalOffset(CChunkAy &chunkAy, + int iFrom, int iTo, int iOffset) +{ + if (!IsValidRange(chunkAy, iFrom, iTo)) + return 0; + + int iChanged = 0; + for (int i = iFrom; i <= iTo; ++i) { + if (!IsTower(chunkAy[i].iSignType) + || chunkAy[i].iSignHorizOffset == iOffset) + continue; + chunkAy[i].iSignHorizOffset = iOffset; + ++iChanged; + } + return iChanged; +} + +//------------------------------------------------------------------------------------------------- + +int CEditorTowerModel::SetVerticalOffset(CChunkAy &chunkAy, + int iFrom, int iTo, int iOffset) +{ + if (!IsValidRange(chunkAy, iFrom, iTo)) + return 0; + + int iChanged = 0; + for (int i = iFrom; i <= iTo; ++i) { + if (!IsTower(chunkAy[i].iSignType) + || chunkAy[i].iSignVertOffset == iOffset) + continue; + chunkAy[i].iSignVertOffset = iOffset; + ++iChanged; + } + return iChanged; +} + +//------------------------------------------------------------------------------------------------- + +int CEditorTowerModel::SetRawType(CChunkAy &chunkAy, int iFrom, int iTo, + int iSignType) +{ + if (!IsValidRange(chunkAy, iFrom, iTo) || !IsTower(iSignType)) + return 0; + + int iChanged = 0; + for (int i = iFrom; i <= iTo; ++i) { + if (!IsTower(chunkAy[i].iSignType) + || chunkAy[i].iSignType == iSignType) + continue; + chunkAy[i].iSignType = iSignType; + ++iChanged; + } + return iChanged; +} diff --git a/TrackEditor/EditorTowerModel.h b/TrackEditor/EditorTowerModel.h new file mode 100644 index 00000000..0ae0adc2 --- /dev/null +++ b/TrackEditor/EditorTowerModel.h @@ -0,0 +1,56 @@ +#ifndef TRACKEDITOR_EDITORTOWERMODEL_H +#define TRACKEDITOR_EDITORTOWERMODEL_H + +#include "TrackModel.h" + +// The five file-loadable camera modes stored in iSignType's low nibble. +enum class eEditorTowerMode +{ + STATIC = -1, + FOLLOW_NEAR = -2, + FOLLOW_AT_DISTANCE = -3, + TRACK_SURFACE_TWO_BACK = -4, + OVERHEAD_FOLLOW = -5 +}; + +// Qt-free file-format codec and range editor used by CEditTowerWidget. Keeping +// these rules out of the widget makes preservation of non-canonical values and +// the sign/tower mutual lockout directly testable. +class CEditorTowerModel +{ +public: + static constexpr int TOWER_TYPE_BASE = 256; + static constexpr int TOWER_LIMIT = 32; + static constexpr int MAX_CANONICAL_ZOOM = 4; + + static bool IsTower(int iSignType); + static bool IsSign(int iSignType); + static eEditorTowerMode DecodeMode(int iSignType); + // Returns 0 for both canonical "Unchanged" and unsupported zoom selectors, + // because the runtime leaves VIEWDIST unchanged in either case. + static int DecodeZoom(int iSignType); + static int Encode(eEditorTowerMode mode, int iZoom); + static bool IsCanonical(int iSignType); + static bool UsesHorizontalOffset(eEditorTowerMode mode); + static bool UsesVerticalOffset(eEditorTowerMode mode); + static int VerticalOffsetScale(eEditorTowerMode mode); + + static int CountTowers(const CChunkAy &chunkAy); + static int AddTowers(CChunkAy &chunkAy, int iFrom, int iTo); + static int DeleteTowers(CChunkAy &chunkAy, int iFrom, int iTo); + static int SetMode(CChunkAy &chunkAy, int iFrom, int iTo, + eEditorTowerMode mode); + static int SetZoom(CChunkAy &chunkAy, int iFrom, int iTo, int iZoom); + static int SetHorizontalOffset(CChunkAy &chunkAy, int iFrom, int iTo, + int iOffset); + static int SetVerticalOffset(CChunkAy &chunkAy, int iFrom, int iTo, + int iOffset); + static int SetRawType(CChunkAy &chunkAy, int iFrom, int iTo, + int iSignType); + +private: + static bool IsValidRange(const CChunkAy &chunkAy, int iFrom, int iTo); + static int ModeNibble(eEditorTowerMode mode); +}; + +#endif diff --git a/TrackEditor/MainWindow.cpp b/TrackEditor/MainWindow.cpp index 6f8d1b43..106c442e 100644 --- a/TrackEditor/MainWindow.cpp +++ b/TrackEditor/MainWindow.cpp @@ -23,6 +23,7 @@ #include "EditSignWidget.h" #include "EditAudioWidget.h" #include "EditStuntWidget.h" +#include "EditTowerWidget.h" #include "qtextstream.h" #include "QtHelpers.h" #include "Logging.h" @@ -104,6 +105,7 @@ class CMainWindowPrivate QDockWidget *m_pEditSignDockWidget; QDockWidget *m_pEditAudioDockWidget; QDockWidget *m_pEditStuntDockWidget; + QDockWidget *m_pEditTowerDockWidget; CDisplaySettings *m_pDisplaySettings; QAction *m_pDebugAction; std::vector m_previewAy; @@ -178,11 +180,19 @@ CMainWindow::CMainWindow(const QString &sAppPath, float fDesktopScale, p->m_pEditStuntDockWidget->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); p->m_pEditStuntDockWidget->setWidget(new CEditStuntWidget(p->m_pEditStuntDockWidget)); + p->m_pEditTowerDockWidget = new QDockWidget("Edit Towers", this); + p->m_pEditTowerDockWidget->setObjectName("EditTowers"); + p->m_pEditTowerDockWidget->setAllowedAreas( + Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); + p->m_pEditTowerDockWidget->setWidget( + new CEditTowerWidget(p->m_pEditTowerDockWidget)); + //setup view menu menuView->addAction(p->m_pEditGeometryDockWidget->toggleViewAction()); menuView->addAction(p->m_pEditSignDockWidget->toggleViewAction()); menuView->addAction(p->m_pEditAudioDockWidget->toggleViewAction()); menuView->addAction(p->m_pEditStuntDockWidget->toggleViewAction()); + menuView->addAction(p->m_pEditTowerDockWidget->toggleViewAction()); menuView->addAction(p->m_pEditSeriesDockWidget->toggleViewAction()); menuView->addAction(p->m_pGlobalSettingsDockWidget->toggleViewAction()); menuView->addAction(p->m_pDisplaySettingsDockWidget->toggleViewAction()); @@ -1325,6 +1335,7 @@ void CMainWindow::LoadSettings() bool bShowEditSign = false; bool bShowEditAudio = false; bool bShowEditStunt = false; + bool bShowEditTower = false; bShowDebugData = settings.value("show_debug_data", bShowDebugData).toBool(); bShowGlobalSettings = settings.value("show_global_settings", bShowGlobalSettings).toBool(); bShowEditSeries = settings.value("show_edit_series", bShowEditSeries).toBool(); @@ -1333,6 +1344,8 @@ void CMainWindow::LoadSettings() bShowEditSign = settings.value("show_edit_sign", bShowEditSign).toBool(); bShowEditAudio = settings.value("show_edit_audio", bShowEditAudio).toBool(); bShowEditStunt = settings.value("show_edit_stunt", bShowEditStunt).toBool(); + bShowEditTower = settings.value( + "show_edit_tower", bShowEditTower).toBool(); p->m_pDebugDataDockWidget->setVisible(bShowDebugData); p->m_pGlobalSettingsDockWidget->setVisible(bShowGlobalSettings); p->m_pEditSeriesDockWidget->setVisible(bShowEditSeries); @@ -1349,6 +1362,11 @@ void CMainWindow::LoadSettings() restoreDockWidget(p->m_pEditSignDockWidget); restoreDockWidget(p->m_pEditAudioDockWidget); restoreDockWidget(p->m_pEditStuntDockWidget); + // Existing window_state values predate this dock. Give it a stable home + // when restoreState has no saved entry, without resetting other docks. + if (!restoreDockWidget(p->m_pEditTowerDockWidget)) + addDockWidget(Qt::RightDockWidgetArea, p->m_pEditTowerDockWidget); + p->m_pEditTowerDockWidget->setVisible(bShowEditTower); } else { addDockWidget(Qt::LeftDockWidgetArea, p->m_pDebugDataDockWidget); addDockWidget(Qt::RightDockWidgetArea, p->m_pGlobalSettingsDockWidget); @@ -1358,6 +1376,7 @@ void CMainWindow::LoadSettings() addDockWidget(Qt::RightDockWidgetArea, p->m_pEditSignDockWidget); addDockWidget(Qt::RightDockWidgetArea, p->m_pEditAudioDockWidget); addDockWidget(Qt::RightDockWidgetArea, p->m_pEditStuntDockWidget); + addDockWidget(Qt::RightDockWidgetArea, p->m_pEditTowerDockWidget); p->m_pDebugDataDockWidget->setVisible(false); p->m_pGlobalSettingsDockWidget->setVisible(false); p->m_pEditSeriesDockWidget->setVisible(false); @@ -1366,6 +1385,7 @@ void CMainWindow::LoadSettings() p->m_pEditSignDockWidget->setVisible(false); p->m_pEditAudioDockWidget->setVisible(false); p->m_pEditStuntDockWidget->setVisible(false); + p->m_pEditTowerDockWidget->setVisible(false); } //get default display settings @@ -1472,6 +1492,7 @@ void CMainWindow::SaveSettings() settings.setValue("show_edit_sign", p->m_pEditSignDockWidget->isVisible()); settings.setValue("show_edit_audio", p->m_pEditAudioDockWidget->isVisible()); settings.setValue("show_edit_stunt", p->m_pEditStuntDockWidget->isVisible()); + settings.setValue("show_edit_tower", p->m_pEditTowerDockWidget->isVisible()); settings.setValue("show_models", p->m_pDisplaySettings->GetDisplaySettings(carModel, aiLine, bMillionPlus)); settings.setValue("show_features", p->m_pDisplaySettings->GetFeatureSettings()); diff --git a/tests/editor_tower_model_test.cpp b/tests/editor_tower_model_test.cpp new file mode 100644 index 00000000..4a819714 --- /dev/null +++ b/tests/editor_tower_model_test.cpp @@ -0,0 +1,196 @@ +#include "EditorTowerModel.h" + +#ifdef NDEBUG +#undef NDEBUG +#endif +#include +#include +#include + +namespace +{ +tGeometryChunk ChunkWithType(int iSignType) +{ + tGeometryChunk Chunk; + Chunk.Default(); + Chunk.iSignType = iSignType; + return Chunk; +} + +void test_every_mode_and_zoom_round_trips_canonically() +{ + const eEditorTowerMode aModes[] = { + eEditorTowerMode::STATIC, + eEditorTowerMode::FOLLOW_NEAR, + eEditorTowerMode::FOLLOW_AT_DISTANCE, + eEditorTowerMode::TRACK_SURFACE_TWO_BACK, + eEditorTowerMode::OVERHEAD_FOLLOW + }; + + for (const eEditorTowerMode mode : aModes) { + for (int iZoom = 0; iZoom <= 4; ++iZoom) { + const int iRaw = CEditorTowerModel::Encode(mode, iZoom); + assert(iRaw >= 256 && iRaw < 336); + assert(CEditorTowerModel::DecodeMode(iRaw) == mode); + assert(CEditorTowerModel::DecodeZoom(iRaw) == iZoom); + assert(CEditorTowerModel::IsCanonical(iRaw)); + } + } + + assert(CEditorTowerModel::Encode(eEditorTowerMode::TRACK_SURFACE_TWO_BACK, 0) + == 0x101); + assert(CEditorTowerModel::Encode(eEditorTowerMode::FOLLOW_NEAR, 4) == 0x143); + assert(CEditorTowerModel::Encode(eEditorTowerMode::OVERHEAD_FOLLOW, 2) == 0x124); + assert(CEditorTowerModel::Encode(eEditorTowerMode::FOLLOW_AT_DISTANCE, 3) + == 0x135); +} + +void test_lossy_values_decode_best_effort_without_becoming_canonical() +{ + // Zoom selectors 5+ have the same runtime behavior as zero: VIEWDIST is + // unchanged. Their raw spelling must nevertheless survive until a decoded + // control is edited. + const int iZoomNineFollowNear = 256 + 16 * 9 + 3; + assert(CEditorTowerModel::DecodeMode(iZoomNineFollowNear) + == eEditorTowerMode::FOLLOW_NEAR); + assert(CEditorTowerModel::DecodeZoom(iZoomNineFollowNear) == 0); + assert(!CEditorTowerModel::IsCanonical(iZoomNineFollowNear)); + + assert(CEditorTowerModel::DecodeMode(512) == eEditorTowerMode::STATIC); + assert(CEditorTowerModel::DecodeZoom(512) == 0); + assert(!CEditorTowerModel::IsCanonical(512)); + assert(!CEditorTowerModel::IsCanonical(258)); // unknown low nibble +} + +void test_mode_specific_offset_rules_match_the_runtime() +{ + assert(CEditorTowerModel::UsesHorizontalOffset(eEditorTowerMode::STATIC)); + assert(CEditorTowerModel::UsesVerticalOffset(eEditorTowerMode::STATIC)); + assert(!CEditorTowerModel::UsesHorizontalOffset( + eEditorTowerMode::TRACK_SURFACE_TWO_BACK)); + assert(!CEditorTowerModel::UsesVerticalOffset( + eEditorTowerMode::TRACK_SURFACE_TWO_BACK)); + assert(!CEditorTowerModel::UsesHorizontalOffset( + eEditorTowerMode::OVERHEAD_FOLLOW)); + assert(CEditorTowerModel::UsesVerticalOffset( + eEditorTowerMode::OVERHEAD_FOLLOW)); + assert(CEditorTowerModel::VerticalOffsetScale( + eEditorTowerMode::OVERHEAD_FOLLOW) == 128); + assert(CEditorTowerModel::VerticalOffsetScale(eEditorTowerMode::STATIC) == 32); +} + +void test_range_lifecycle_skips_signs_and_obeys_the_budget() +{ + CChunkAy chunkAy = { + ChunkWithType(-1), ChunkWithType(9), ChunkWithType(-1), + ChunkWithType(CEditorTowerModel::Encode(eEditorTowerMode::STATIC, 0)) + }; + + assert(CEditorTowerModel::AddTowers(chunkAy, 0, 3) == 2); + assert(CEditorTowerModel::IsTower(chunkAy[0].iSignType)); + assert(chunkAy[1].iSignType == 9); + assert(CEditorTowerModel::IsTower(chunkAy[2].iSignType)); + assert(CEditorTowerModel::CountTowers(chunkAy) == 3); + + assert(CEditorTowerModel::DeleteTowers(chunkAy, 0, 3) == 3); + assert(chunkAy[0].iSignType == -1); + assert(chunkAy[1].iSignType == 9); + assert(chunkAy[2].iSignType == -1); + assert(chunkAy[3].iSignType == -1); + + CChunkAy budgetAy(34, ChunkWithType(-1)); + for (int i = 0; i < 31; ++i) + budgetAy[i].iSignType = 256; + budgetAy[31].iSignType = 4; + assert(CEditorTowerModel::AddTowers(budgetAy, 31, 33) == 1); + assert(budgetAy[31].iSignType == 4); + assert(CEditorTowerModel::CountTowers(budgetAy) == 32); + assert(CEditorTowerModel::AddTowers(budgetAy, 31, 33) == 0); +} + +void test_range_edits_touch_existing_towers_only() +{ + CChunkAy chunkAy = { + ChunkWithType(CEditorTowerModel::Encode(eEditorTowerMode::STATIC, 2)), + ChunkWithType(7), + ChunkWithType(-1), + ChunkWithType(256 + 16 * 9 + 3) + }; + + assert(CEditorTowerModel::SetMode( + chunkAy, 0, 3, eEditorTowerMode::OVERHEAD_FOLLOW) == 2); + assert(chunkAy[0].iSignType + == CEditorTowerModel::Encode(eEditorTowerMode::OVERHEAD_FOLLOW, 2)); + // The unsupported zoom canonicalizes to its best-effort runtime behavior. + assert(chunkAy[3].iSignType + == CEditorTowerModel::Encode(eEditorTowerMode::OVERHEAD_FOLLOW, 0)); + assert(chunkAy[1].iSignType == 7); + assert(chunkAy[2].iSignType == -1); + + assert(CEditorTowerModel::SetHorizontalOffset(chunkAy, 0, 3, -12) == 2); + assert(CEditorTowerModel::SetVerticalOffset(chunkAy, 0, 3, 45) == 2); + assert(chunkAy[0].iSignHorizOffset == -12); + assert(chunkAy[3].iSignVertOffset == 45); + assert(chunkAy[1].iSignHorizOffset == 0); + + assert(CEditorTowerModel::SetRawType(chunkAy, 0, 3, 777) == 2); + assert(chunkAy[0].iSignType == 777); + assert(chunkAy[3].iSignType == 777); + assert(chunkAy[1].iSignType == 7); + assert(CEditorTowerModel::SetRawType(chunkAy, 0, 3, 10) == 0); +} + +void test_tower_fields_round_trip_and_history_restores_them() +{ + CTrackModel track; + track.m_chunkAy = { ChunkWithType(-1), ChunkWithType(-1) }; + CTrackHistory history; + history.Save(track, "before tower", 8); + + assert(CEditorTowerModel::AddTowers(track.m_chunkAy, 0, 0) == 1); + assert(CEditorTowerModel::SetMode( + track.m_chunkAy, 0, 0, eEditorTowerMode::FOLLOW_AT_DISTANCE) == 1); + assert(CEditorTowerModel::SetZoom(track.m_chunkAy, 0, 0, 4) == 1); + assert(CEditorTowerModel::SetHorizontalOffset(track.m_chunkAy, 0, 0, -123) == 1); + assert(CEditorTowerModel::SetVerticalOffset(track.m_chunkAy, 0, 0, 456) == 1); + history.Save(track, "edited tower", 8); + + std::vector bytes; + track.GetTrackData(bytes); + CTrackModel loaded; + assert(loaded.ProcessTrackData(bytes.data(), bytes.size())); + assert(loaded.m_chunkAy[0].iSignType + == CEditorTowerModel::Encode(eEditorTowerMode::FOLLOW_AT_DISTANCE, 4)); + assert(loaded.m_chunkAy[0].iSignHorizOffset == -123); + assert(loaded.m_chunkAy[0].iSignVertOffset == 456); + std::vector roundTrip; + loaded.GetTrackData(roundTrip); + assert(roundTrip == bytes); + + assert(history.Undo(track)); + assert(track.m_chunkAy[0].iSignType == -1); + assert(history.Redo(track)); + assert(track.m_chunkAy[0].iSignType + == CEditorTowerModel::Encode(eEditorTowerMode::FOLLOW_AT_DISTANCE, 4)); + + // An unsupported raw value is ordinary document data and survives when no + // decoded control canonicalizes it. + track.m_chunkAy[1].iSignType = 777; + bytes.clear(); + track.GetTrackData(bytes); + CTrackModel loadedRaw; + assert(loadedRaw.ProcessTrackData(bytes.data(), bytes.size())); + assert(loadedRaw.m_chunkAy[1].iSignType == 777); +} +} + +int main() +{ + test_every_mode_and_zoom_round_trips_canonically(); + test_lossy_values_decode_best_effort_without_becoming_canonical(); + test_mode_specific_offset_rules_match_the_runtime(); + test_range_lifecycle_skips_signs_and_obeys_the_budget(); + test_range_edits_touch_existing_towers_only(); + test_tower_fields_round_trip_and_history_restores_them(); + return 0; +} diff --git a/tests/test_e7_s4_tower_display.py b/tests/test_e7_s4_tower_display.py index 526a2273..a38b28d8 100644 --- a/tests/test_e7_s4_tower_display.py +++ b/tests/test_e7_s4_tower_display.py @@ -64,22 +64,22 @@ def test_the_translator_maps_only_the_new_word_to_the_facade_flag(self) -> None: class DisplaySettingsTests(unittest.TestCase): - def test_the_default_on_checkbox_sits_beside_stunt_markers(self) -> None: + def test_the_default_on_checkbox_joins_the_marker_controls(self) -> None: tree = ET.parse(EDITOR / "DisplaySettings.ui") - positions: dict[str, tuple[str | None, str | None]] = {} towers = None - for item in tree.iter("item"): - widget = item.find("widget") - if widget is None: - continue - name = widget.attrib.get("name") - if name in ("ckStunts", "ckTowers"): - positions[name] = (item.attrib.get("row"), item.attrib.get("column")) - if name == "ckTowers": - towers = widget - - self.assertEqual(positions["ckTowers"][0], positions["ckStunts"][0]) - self.assertNotEqual(positions["ckTowers"][1], positions["ckStunts"][1]) + marker_group = None + for layout in tree.iter("layout"): + direct_names = { + widget.attrib.get("name") + for item in layout.findall("item") + for widget in item.findall("widget") + } + if {"ckStunts", "ckTowers"}.issubset(direct_names): + marker_group = layout + break + + self.assertIsNotNone(marker_group) + towers = marker_group.find("item/widget[@name='ckTowers']") self.assertIsNotNone(towers) self.assertEqual(towers.findtext("property[@name='text']/string"), "Towers") self.assertEqual(towers.findtext("property[@name='checked']/bool"), "true") diff --git a/tests/test_e7_s5_edit_towers.py b/tests/test_e7_s5_edit_towers.py new file mode 100644 index 00000000..28a7c774 --- /dev/null +++ b/tests/test_e7_s5_edit_towers.py @@ -0,0 +1,213 @@ +import re +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +EDITOR = ROOT / "TrackEditor" + + +def read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def function_body(text: str, signature: str) -> str: + start = text.index(signature) + brace = text.index("{", start) + depth = 0 + for position in range(brace, len(text)): + if text[position] == "{": + depth += 1 + elif text[position] == "}": + depth -= 1 + if depth == 0: + return text[start : position + 1] + raise AssertionError(f"unterminated function: {signature}") + + +class DockIntegrationTests(unittest.TestCase): + def test_the_edit_towers_dock_follows_the_existing_dock_convention(self) -> None: + window = read(EDITOR / "MainWindow.cpp") + for contract in ( + 'new QDockWidget("Edit Towers", this)', + 'setObjectName("EditTowers")', + "Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea", + "new CEditTowerWidget(", + ): + self.assertIn(contract, window) + + stunt_action = window.index( + "m_pEditStuntDockWidget->toggleViewAction()" + ) + tower_action = window.index( + "m_pEditTowerDockWidget->toggleViewAction()" + ) + series_action = window.index( + "m_pEditSeriesDockWidget->toggleViewAction()" + ) + self.assertLess(stunt_action, tower_action) + self.assertLess(tower_action, series_action) + + def test_dock_visibility_persists_without_resetting_old_profiles(self) -> None: + window = read(EDITOR / "MainWindow.cpp") + self.assertIn('settings.value(\n "show_edit_tower"', window) + self.assertIn('settings.setValue("show_edit_tower"', window) + self.assertIn("restoreDockWidget(p->m_pEditTowerDockWidget)", window) + + # The compatibility gate intentionally remains on the old keys: a + # profile written before E7-S5 must retain all of its existing layout. + gate_start = window.index("if (settings.contains(\"show_debug_data\")") + gate_end = window.index(") {", gate_start) + self.assertNotIn("show_edit_tower", window[gate_start:gate_end]) + + def test_the_widget_and_model_are_application_sources(self) -> None: + cmake = read(EDITOR / "CMakeLists.txt") + for name in ( + "EditTowerWidget.cpp", + "EditTowerWidget.h", + "EditTowerWidget.ui", + "EditorTowerModel.cpp", + "EditorTowerModel.h", + ): + self.assertIn(name, cmake) + + +class ControlsTests(unittest.TestCase): + def setUp(self) -> None: + self.widget = read(EDITOR / "EditTowerWidget.cpp") + self.ui_root = ET.parse(EDITOR / "EditTowerWidget.ui").getroot() + + def test_all_required_controls_are_present(self) -> None: + names = { + node.attrib.get("name") for node in self.ui_root.iter("widget") + } + self.assertTrue( + { + "pbTower", + "cbMode", + "cbZoom", + "sbHOffset", + "sbVOffset", + "leRawType", + "lblBudget", + "lblSignDisabled", + "lblOffsetScale", + "lblRawPreserved", + }.issubset(names) + ) + ui = read(EDITOR / "EditTowerWidget.ui") + self.assertIn("Sign exists on this chunk", ui) + self.assertIn("0 of 32", ui) + + def test_the_five_camera_modes_have_readable_names(self) -> None: + for name in ( + "Static", + "Follow near (25%)", + "Follow at distance", + "Track surface, 2 back", + "Overhead follow", + ): + self.assertIn(f'cbMode->addItem("{name}"', self.widget) + + def test_zoom_choices_publish_the_runtime_viewdist_values(self) -> None: + for text, value in ( + ("Unchanged", 0), + ("1 - VIEWDIST 120", 1), + ("2 - VIEWDIST 75", 2), + ("3 - VIEWDIST 500", 3), + ("4 - VIEWDIST 750", 4), + ): + self.assertIn(f'cbZoom->addItem("{text}", {value});', self.widget) + + def test_mode_specific_offsets_are_disabled_and_overhead_notes_x128(self) -> None: + model = read(EDITOR / "EditorTowerModel.cpp") + self.assertIn("UsesHorizontalOffset", self.widget) + self.assertIn("UsesVerticalOffset", self.widget) + self.assertIn("eEditorTowerMode::TRACK_SURFACE_TWO_BACK", model) + self.assertIn("eEditorTowerMode::OVERHEAD_FOLLOW", model) + self.assertIn("? 128 : 32", model) + self.assertIn("vertical offset x128 at runtime", self.widget) + + +class EncodingTests(unittest.TestCase): + def test_the_codec_mirrors_the_verified_file_format(self) -> None: + codec = read(EDITOR / "EditorTowerModel.cpp") + for masked, mode in ( + ("0x101", "TRACK_SURFACE_TWO_BACK"), + ("0x103", "FOLLOW_NEAR"), + ("0x104", "OVERHEAD_FOLLOW"), + ("0x105", "FOLLOW_AT_DISTANCE"), + ): + self.assertRegex(codec, rf"case {masked}: return .*::{mode};") + self.assertIn("TOWER_TYPE_BASE + 16 * iCanonicalZoom + ModeNibble(mode)", codec) + + def test_noncanonical_raw_values_are_preserved_until_a_decoded_edit(self) -> None: + widget = read(EDITOR / "EditTowerWidget.cpp") + model = read(EDITOR / "EditorTowerModel.cpp") + self.assertIn("BLOCK_SIG_AND_DO(leRawType, setText(", widget) + self.assertIn("!CEditorTowerModel::IsCanonical(Chunk.iSignType)", widget) + self.assertIn("CEditorTowerModel::SetRawType(", widget) + self.assertIn("Encode(mode, DecodeZoom(chunkAy[i].iSignType))", model) + self.assertIn("Encode(DecodeMode(chunkAy[i].iSignType), iZoom)", model) + + +class SafetyAndHistoryTests(unittest.TestCase): + def test_sign_chunks_are_a_red_mutual_lockout(self) -> None: + widget = read(EDITOR / "EditTowerWidget.cpp") + self.assertIn("CEditorTowerModel::IsSign(Chunk.iSignType)", widget) + self.assertIn('lblSignDisabled->setStyleSheet("QLabel { color : red; }")', widget) + self.assertIn("lblSignDisabled->setVisible(bHasSign)", widget) + self.assertIn("lblSignDisabled->setEnabled(true)", widget) + self.assertIn("pbTower->setEnabled(bHasTower || bCanAdd)", widget) + + for control in ("cbMode", "cbZoom", "leRawType"): + self.assertIn(f"{control}->setEnabled(bHasTower)", widget) + + def test_range_writes_skip_non_towers_and_add_never_replaces_a_sign(self) -> None: + model = read(EDITOR / "EditorTowerModel.cpp") + add = function_body(model, "int CEditorTowerModel::AddTowers") + self.assertIn("chunkAy[i].iSignType != -1", add) + self.assertIn("iTowerCount < TOWER_LIMIT", add) + + for signature in ( + "DeleteTowers", + "SetMode", + "SetZoom", + "SetHorizontalOffset", + "SetVerticalOffset", + "SetRawType", + ): + body = function_body(model, f"int CEditorTowerModel::{signature}") + self.assertIn("IsTower(chunkAy[i].iSignType)", body) + + def test_the_32_tower_budget_has_a_visible_refusal_reason(self) -> None: + header = read(EDITOR / "EditorTowerModel.h") + widget = read(EDITOR / "EditTowerWidget.cpp") + self.assertIn("TOWER_LIMIT = 32", header) + self.assertIn('sBudget += " - tower limit reached"', widget) + self.assertIn("iTowerCount < CEditorTowerModel::TOWER_LIMIT", widget) + + def test_every_mutating_control_uses_history_and_refreshes_the_dock(self) -> None: + widget = read(EDITOR / "EditTowerWidget.cpp") + commit = function_body(widget, "void CEditTowerWidget::CommitEdit") + self.assertIn("g_pMainWindow->SaveHistory(sDescription);", commit) + self.assertIn("g_pMainWindow->UpdateWindow();", commit) + self.assertGreaterEqual(widget.count("CommitEdit("), 7) + + +class NativeCoverageTests(unittest.TestCase): + def test_codec_range_roundtrip_and_history_have_a_native_target(self) -> None: + cmake = read(ROOT / "CMakeLists.txt") + native = read(ROOT / "tests" / "editor_tower_model_test.cpp") + self.assertIn("trackeditor-e7-s5-tower-model-test", cmake) + self.assertIn("test_every_mode_and_zoom_round_trips_canonically", native) + self.assertIn("test_range_lifecycle_skips_signs_and_obeys_the_budget", native) + self.assertIn("roundTrip == bytes", native) + self.assertIn("history.Undo(track)", native) + self.assertIn("history.Redo(track)", native) + self.assertIn("iSignType == 777", native) + + +if __name__ == "__main__": + unittest.main() From 550e6ed6d0e1aef687d5c87a1a26ed0462754fda Mon Sep 17 00:00:00 2001 From: Zizin13 <162662805+Zizin13@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:18:53 -0700 Subject: [PATCH 3/4] view from tower button --- CMakeLists.txt | 5 + TrackEditor/EditTowerWidget.cpp | 21 ++++ TrackEditor/EditTowerWidget.h | 1 + TrackEditor/EditTowerWidget.ui | 36 +++--- TrackEditor/EditorCameraController.cpp | 25 +++++ TrackEditor/EditorCameraController.h | 4 + TrackEditor/EditorRenderQueue.h | 5 + TrackEditor/EditorRenderService.cpp | 44 ++++++++ TrackEditor/TrackPreview.cpp | 30 +++++ TrackEditor/TrackPreview.h | 2 + tests/editor_camera_controller_test.cpp | 46 ++++++++ tests/editor_render_service_test.cpp | 69 ++++++++++++ tests/test_e7_s6_view_from_tower.py | 140 ++++++++++++++++++++++++ 13 files changed, 415 insertions(+), 13 deletions(-) create mode 100644 tests/test_e7_s6_view_from_tower.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 222c5fa3..b2c9e579 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -222,6 +222,11 @@ if(BUILD_TESTING) COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_e7_s5_edit_towers.py" -v) + add_test( + NAME trackeditor-e7-s6-view-from-tower-contract + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_e7_s6_view_from_tower.py" + -v) add_test( NAME trackeditor-graphics-settings-contract COMMAND "${Python3_EXECUTABLE}" diff --git a/TrackEditor/EditTowerWidget.cpp b/TrackEditor/EditTowerWidget.cpp index f9896eea..11d05798 100644 --- a/TrackEditor/EditTowerWidget.cpp +++ b/TrackEditor/EditTowerWidget.cpp @@ -4,6 +4,7 @@ #include "MainWindow.h" #include "QtHelpers.h" #include "Track.h" +#include "TrackPreview.h" #include #include @@ -44,6 +45,8 @@ CEditTowerWidget::CEditTowerWidget(QWidget *pParent) this, &CEditTowerWidget::UpdateGeometrySelection); connect(pbTower, &QPushButton::clicked, this, &CEditTowerWidget::TowerClicked); + connect(pbViewFromTower, &QPushButton::clicked, + this, &CEditTowerWidget::ViewFromTowerClicked); connect(cbMode, SIGNAL(currentIndexChanged(int)), this, SLOT(ModeChanged(int))); connect(cbZoom, SIGNAL(currentIndexChanged(int)), @@ -105,6 +108,7 @@ void CEditTowerWidget::UpdateGeometrySelection(int iFrom, int iTo) && iTowerCount < CEditorTowerModel::TOWER_LIMIT; pbTower->setText(bHasTower ? "Delete Tower" : "Add Tower"); pbTower->setEnabled(bHasTower || bCanAdd); + pbViewFromTower->setEnabled(bHasTower); cbMode->setEnabled(bHasTower); cbZoom->setEnabled(bHasTower); @@ -180,6 +184,23 @@ void CEditTowerWidget::TowerClicked() //------------------------------------------------------------------------------------------------- +void CEditTowerWidget::ViewFromTowerClicked() +{ + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (!GetSelection(pTrack, iFrom, iTo) + || !CEditorTowerModel::IsTower(pTrack->m_chunkAy[iFrom].iSignType)) { + return; + } + + CTrackPreview *pPreview = g_pMainWindow->GetCurrentPreview(); + if (pPreview) + pPreview->ViewFromTower(iFrom); +} + +//------------------------------------------------------------------------------------------------- + void CEditTowerWidget::ModeChanged(int iIndex) { CTrack *pTrack = nullptr; diff --git a/TrackEditor/EditTowerWidget.h b/TrackEditor/EditTowerWidget.h index c38d595d..728419bf 100644 --- a/TrackEditor/EditTowerWidget.h +++ b/TrackEditor/EditTowerWidget.h @@ -16,6 +16,7 @@ class CEditTowerWidget : public QWidget, private Ui::EditTowerWidget protected slots: void UpdateGeometrySelection(int iFrom, int iTo); void TowerClicked(); + void ViewFromTowerClicked(); void ModeChanged(int iIndex); void ZoomChanged(int iIndex); void HOffsetChanged(int iValue); diff --git a/TrackEditor/EditTowerWidget.ui b/TrackEditor/EditTowerWidget.ui index a4897b30..e954a186 100644 --- a/TrackEditor/EditTowerWidget.ui +++ b/TrackEditor/EditTowerWidget.ui @@ -62,40 +62,50 @@ + + + false + + + View from tower + + + + Sign exists on this chunk - + Camera Mode - + - + Zoom - + - + Horizontal Offset - + -65535 @@ -105,14 +115,14 @@ - + Vertical Offset - + -65535 @@ -122,7 +132,7 @@ - + Offsets use x32 track units at runtime. @@ -132,17 +142,17 @@ - + Raw iSignType - + - + Non-canonical value preserved until Mode or Zoom changes. @@ -152,7 +162,7 @@ - + Qt::Vertical diff --git a/TrackEditor/EditorCameraController.cpp b/TrackEditor/EditorCameraController.cpp index 1421a9f2..1e1d2b1a 100644 --- a/TrackEditor/EditorCameraController.cpp +++ b/TrackEditor/EditorCameraController.cpp @@ -124,6 +124,31 @@ void CEditorCameraController::ResetMouseTracking() m_bHasPreviousMousePosition = false; } +bool CEditorCameraController::CalculateLookAtOrientation( + const float fPosition[3], const float fTarget[3], + float &fYawDegreesOut, float &fPitchDegreesOut) +{ + if (!fPosition || !fTarget) + return false; + for (int i = 0; i < 3; ++i) { + if (!std::isfinite(fPosition[i]) || !std::isfinite(fTarget[i])) + return false; + } + + const float fDeltaX = fTarget[0] - fPosition[0]; + const float fDeltaY = fTarget[1] - fPosition[1]; + const float fDeltaZ = fTarget[2] - fPosition[2]; + const float fHorizontalDistance = std::hypot(fDeltaX, fDeltaY); + if (fHorizontalDistance == 0.0f && fDeltaZ == 0.0f) + return false; + + constexpr float RADIANS_TO_DEGREES = 180.0f / PI; + fYawDegreesOut = std::atan2(fDeltaY, fDeltaX) * RADIANS_TO_DEGREES; + fPitchDegreesOut = std::atan2(fDeltaZ, fHorizontalDistance) + * RADIANS_TO_DEGREES; + return true; +} + void CEditorCameraController::SetMovementSpeed(float fMovementSpeed) { if (std::isfinite(fMovementSpeed) && fMovementSpeed > 0.0f) diff --git a/TrackEditor/EditorCameraController.h b/TrackEditor/EditorCameraController.h index 17d91a3f..14a4760a 100644 --- a/TrackEditor/EditorCameraController.h +++ b/TrackEditor/EditorCameraController.h @@ -29,6 +29,10 @@ class CEditorCameraController void SetPosition(float fX, float fY, float fZ); void SetOrientation(float fYawDegrees, float fPitchDegrees); void ResetMouseTracking(); + static bool CalculateLookAtOrientation(const float fPosition[3], + const float fTarget[3], + float &fYawDegreesOut, + float &fPitchDegreesOut); static void SetMovementSpeed(float fMovementSpeed); static float GetMovementSpeed() { return s_fMovementSpeed; } diff --git a/TrackEditor/EditorRenderQueue.h b/TrackEditor/EditorRenderQueue.h index 549f5f9b..cb95756d 100644 --- a/TrackEditor/EditorRenderQueue.h +++ b/TrackEditor/EditorRenderQueue.h @@ -114,6 +114,11 @@ struct tEdRenderResult uint32_t uiRenderedGeometryEpoch = 0; bool bLoadFailed = false; bool bSceneEmpty = false; + // E7-S6. LOAD results replace the document's committed tower table. A + // render-only result leaves it alone, so camera motion cannot accidentally + // discard the positions obtained by the worker during the last load. + std::vector Towers; + bool bHasTowerSnapshot = false; QImage Image; std::string sErrorText; }; diff --git a/TrackEditor/EditorRenderService.cpp b/TrackEditor/EditorRenderService.cpp index 23f93e19..d355f80b 100644 --- a/TrackEditor/EditorRenderService.cpp +++ b/TrackEditor/EditorRenderService.cpp @@ -253,6 +253,46 @@ class CEditorRenderThread : public QThread return true; } + // E7-S6. Tower placement belongs to the committed ROLLER scene, so copy it + // out beside the load on this worker. The UI receives only owned structs + // and never calls the facade itself (AD-4e). + bool QueryTowers(tEdRenderResult &Result) + { + uint32_t uiTowerCount = 0; + AssertWorkerThread("RollerEd_QueryTowerCount"); + eRollerEdResult eResult = RollerEd_QueryTowerCount(&uiTowerCount); + if (eResult != ROLLER_ED_RESULT_OK) { + Result.bLoadFailed = true; + SetFacadeFailure(Result, eResult); + return false; + } + + constexpr uint32_t MAX_TOWER_COUNT = 32u; + if (uiTowerCount > MAX_TOWER_COUNT) { + Result.Tag.eResult = ROLLER_ED_RESULT_INVALID_ARGUMENT; + Result.bLoadFailed = true; + Result.sErrorText = "the core published more than 32 towers"; + return false; + } + + Result.Towers.resize(uiTowerCount); + for (uint32_t uiIndex = 0; uiIndex < uiTowerCount; ++uiIndex) { + tEdTowerInfo &Info = Result.Towers[uiIndex]; + Info = {}; + Info.uiStructSize = sizeof(Info); + Info.uiVersion = ROLLER_ED_TOWER_INFO_VERSION; + AssertWorkerThread("RollerEd_QueryTower"); + eResult = RollerEd_QueryTower(uiIndex, &Info); + if (eResult != ROLLER_ED_RESULT_OK) { + Result.Towers.clear(); + Result.bLoadFailed = true; + SetFacadeFailure(Result, eResult); + return false; + } + } + return true; + } + // E4-S1. Query then fill, both on the worker, both against the epoch the // query reported. RollerEd_FillGeometry refuses in a fixed order and writes // nothing on refusal, so a failed extraction leaves the snapshot untouched. @@ -342,6 +382,7 @@ class CEditorRenderThread : public QThread const bool bLoadCommand = Request.eKind != eEdRenderCommandKind::RENDER_ONLY; + Result.bHasTowerSnapshot = bLoadCommand; if (m_eInitResult != ROLLER_ED_RESULT_OK) { Result.Tag.eResult = m_eInitResult; Result.bLoadFailed = bLoadCommand; @@ -455,6 +496,9 @@ class CEditorRenderThread : public QThread return Result; } + if (bLoadCommand && !QueryTowers(Result)) + return Result; + if (Request.uiStuntTicks != 0) { AssertWorkerThread("RollerEd_AdvanceStunts"); const eRollerEdResult eStuntResult = diff --git a/TrackEditor/TrackPreview.cpp b/TrackEditor/TrackPreview.cpp index 04104dd1..067a3036 100644 --- a/TrackEditor/TrackPreview.cpp +++ b/TrackEditor/TrackPreview.cpp @@ -645,6 +645,9 @@ void CTrackPreview::OnRenderCompleted(const tEdRenderResult &Result) return; } + if (Result.bHasTowerSnapshot) + m_Towers = Result.Towers; + m_bReloadPending = Result.Tag.eResult != ROLLER_ED_RESULT_OK; update(); @@ -659,6 +662,33 @@ void CTrackPreview::OnRenderCompleted(const tEdRenderResult &Result) //------------------------------------------------------------------------------------------------- +bool CTrackPreview::ViewFromTower(int iChunkId) +{ + for (const tEdTowerInfo &Tower : m_Towers) { + if (Tower.uiChunkId != static_cast(iChunkId)) + continue; + + float fYawDegrees = 0.0f; + float fPitchDegrees = 0.0f; + if (!CEditorCameraController::CalculateLookAtOrientation( + Tower.fWorldPosition, Tower.fAnchorPosition, + fYawDegrees, fPitchDegrees)) { + return false; + } + + m_CameraController.SetPosition( + Tower.fWorldPosition[0], Tower.fWorldPosition[1], + Tower.fWorldPosition[2]); + m_CameraController.SetOrientation(fYawDegrees, fPitchDegrees); + m_CameraController.ResetMouseTracking(); + ScheduleCameraRender(); + return true; + } + return false; +} + +//------------------------------------------------------------------------------------------------- + void CTrackPreview::Activate() { m_pEditTimer->stop(); diff --git a/TrackEditor/TrackPreview.h b/TrackEditor/TrackPreview.h index 77dfb8e5..52326500 100644 --- a/TrackEditor/TrackPreview.h +++ b/TrackEditor/TrackPreview.h @@ -65,6 +65,7 @@ class CTrackPreview : public QWidget void Activate(); void RefreshGraphicsSettings(); void MarkDocumentEdited(); + bool ViewFromTower(int iChunkId); bool CanExport() const { return m_FrameState.CanExport(); } bool m_bUnsavedChanges; @@ -141,6 +142,7 @@ class CTrackPreview : public QWidget uint64_t m_ullDocumentId; CDocumentFrameState m_FrameState; CEditorCameraController m_CameraController; + std::vector m_Towers; CEditorOverlaySettings m_OverlaySettings; CEditorReferenceMesh m_ReferenceMesh; tEdReferenceMeshPayload m_PendingReferenceMesh; diff --git a/tests/editor_camera_controller_test.cpp b/tests/editor_camera_controller_test.cpp index d80f7c6f..61d9b15a 100644 --- a/tests/editor_camera_controller_test.cpp +++ b/tests/editor_camera_controller_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #ifdef assert #undef assert @@ -118,6 +119,50 @@ void TestMovementFollowsYawAndPitch() assert(NearlyEqual(Camera.GetCameraState().fPitchDegrees, -5.0f)); } +void TestLookAtOrientationUsesEditorDegrees() +{ + const float Position[3] = { 1.0f, 2.0f, 3.0f }; + float fYaw = -999.0f; + float fPitch = -999.0f; + + const float AlongX[3] = { 5.0f, 2.0f, 3.0f }; + assert(CEditorCameraController::CalculateLookAtOrientation( + Position, AlongX, fYaw, fPitch)); + assert(NearlyEqual(fYaw, 0.0f)); + assert(NearlyEqual(fPitch, 0.0f)); + + const float AlongY[3] = { 1.0f, 7.0f, 3.0f }; + assert(CEditorCameraController::CalculateLookAtOrientation( + Position, AlongY, fYaw, fPitch)); + assert(NearlyEqual(fYaw, 90.0f)); + assert(NearlyEqual(fPitch, 0.0f)); + + const float DiagonalUp[3] = { 4.0f, 6.0f, 8.0f }; + assert(CEditorCameraController::CalculateLookAtOrientation( + Position, DiagonalUp, fYaw, fPitch)); + assert(NearlyEqual(fYaw, 53.1301f)); + assert(NearlyEqual(fPitch, 45.0f)); + + const float Vertical[3] = { 1.0f, 2.0f, 13.0f }; + assert(CEditorCameraController::CalculateLookAtOrientation( + Position, Vertical, fYaw, fPitch)); + assert(NearlyEqual(fYaw, 0.0f)); + assert(NearlyEqual(fPitch, 90.0f)); + + fYaw = 17.0f; + fPitch = 23.0f; + assert(!CEditorCameraController::CalculateLookAtOrientation( + Position, Position, fYaw, fPitch)); + assert(NearlyEqual(fYaw, 17.0f)); + assert(NearlyEqual(fPitch, 23.0f)); + + const float Invalid[3] = { + std::numeric_limits::quiet_NaN(), 2.0f, 3.0f + }; + assert(!CEditorCameraController::CalculateLookAtOrientation( + Position, Invalid, fYaw, fPitch)); +} + } int main() @@ -125,6 +170,7 @@ int main() TestFacadeStateAndWorldAxisMovement(); TestMouseLookSensitivityAndClickGate(); TestMovementFollowsYawAndPitch(); + TestLookAtOrientationUsesEditorDegrees(); CEditorCameraController::SetMovementSpeed( CEditorCameraController::DEFAULT_MOVEMENT_SPEED); std::cout << "E3-S3 editor camera input tests passed\n"; diff --git a/tests/editor_render_service_test.cpp b/tests/editor_render_service_test.cpp index 6288bb43..388701fa 100644 --- a/tests/editor_render_service_test.cpp +++ b/tests/editor_render_service_test.cpp @@ -55,6 +55,8 @@ std::atomic g_uiRenderCount(0); std::atomic g_uiStuntTickCount(0); std::atomic g_uiStuntTicksAtLastRender(0); std::atomic g_uiFillCount(0); +std::atomic g_uiTowerCountQueryCount(0); +std::atomic g_uiTowerQueryCount(0); uint32_t g_uiRefusedFillEpoch = 0; // E4-S1. The stubbed extraction is one quad, which is enough to prove the @@ -175,6 +177,49 @@ extern "C" eRollerEdResult ROLLER_ED_CALL RollerEd_QueryGeometrySizes( return ROLLER_ED_RESULT_OK; } +extern "C" eRollerEdResult ROLLER_ED_CALL RollerEd_QueryTowerCount( + uint32_t *puiCountOut) +{ + RecordFacadeThread(); + assert(puiCountOut); + ++g_uiTowerCountQueryCount; + if (g_uiSceneState != ROLLER_ED_SCENE_READY) { + g_sError = "no scene"; + return ROLLER_ED_RESULT_NO_SCENE; + } + *puiCountOut = 2u; + return ROLLER_ED_RESULT_OK; +} + +extern "C" eRollerEdResult ROLLER_ED_CALL RollerEd_QueryTower( + uint32_t uiTowerIndex, tEdTowerInfo *pInfoOut) +{ + RecordFacadeThread(); + assert(pInfoOut); + assert(pInfoOut->uiStructSize == sizeof(*pInfoOut)); + assert(pInfoOut->uiVersion == ROLLER_ED_TOWER_INFO_VERSION); + ++g_uiTowerQueryCount; + if (g_uiSceneState != ROLLER_ED_SCENE_READY) { + g_sError = "no scene"; + return ROLLER_ED_RESULT_NO_SCENE; + } + if (uiTowerIndex >= 2u) { + g_sError = "tower index out of range"; + return ROLLER_ED_RESULT_INVALID_ARGUMENT; + } + + const float fTowerIndex = static_cast(uiTowerIndex); + pInfoOut->uiChunkId = uiTowerIndex == 0u ? 7u : 11u; + pInfoOut->fWorldPosition[0] = 1000.0f + + static_cast(g_uiGeometryEpoch) + 10.0f * fTowerIndex; + pInfoOut->fWorldPosition[1] = 20.0f + fTowerIndex; + pInfoOut->fWorldPosition[2] = 30.0f + fTowerIndex; + pInfoOut->fAnchorPosition[0] = 40.0f + fTowerIndex; + pInfoOut->fAnchorPosition[1] = 50.0f + fTowerIndex; + pInfoOut->fAnchorPosition[2] = 60.0f + fTowerIndex; + return ROLLER_ED_RESULT_OK; +} + extern "C" eRollerEdResult ROLLER_ED_CALL RollerEd_FillGeometry( uint32_t uiExpectedGeometryEpoch, tEdVertex *pVerts, uint32_t uiVertexCapacity, @@ -390,6 +435,15 @@ int main(int argc, char **argv) assert(GoodResult.Tag.uiActualGeometryEpoch == 1); assert(GoodResult.uiRenderedGeometryEpoch == 1); assert(GoodResult.Image.size() == QSize(4, 3)); + assert(GoodResult.bHasTowerSnapshot); + assert(GoodResult.Towers.size() == 2u); + assert(GoodResult.Towers[0].uiStructSize == sizeof(tEdTowerInfo)); + assert(GoodResult.Towers[0].uiVersion == ROLLER_ED_TOWER_INFO_VERSION); + assert(GoodResult.Towers[0].uiChunkId == 7u); + assert(GoodResult.Towers[0].fWorldPosition[0] == 1001.0f); + assert(GoodResult.Towers[0].fAnchorPosition[2] == 60.0f); + assert(g_uiTowerCountQueryCount.load() == 1u); + assert(g_uiTowerQueryCount.load() == 2u); assert(Document.ApplyResult(GoodResult)); assert(Document.CanExport()); @@ -419,6 +473,8 @@ int main(int argc, char **argv) const tEdRenderResult MeshResult = WaitForResult(Service, ullMeshRequest); assert(MeshResult.Tag.eResult == ROLLER_ED_RESULT_OK); + assert(!MeshResult.bHasTowerSnapshot); + assert(MeshResult.Towers.empty()); assert(g_uiReferenceMeshCount == 1); assert(g_uiReferenceVertexCount == 3); assert(g_uiReferenceIndexCount == 3); @@ -435,6 +491,8 @@ int main(int argc, char **argv) const tEdRenderResult PlainResult = WaitForResult(Service, ullPlainRequest); assert(PlainResult.Tag.eResult == ROLLER_ED_RESULT_OK); + assert(!PlainResult.bHasTowerSnapshot); + assert(PlainResult.Towers.empty()); assert(g_uiReferenceMeshCount == 1); // Stunt ticks are copied into the command and applied on the same worker @@ -447,6 +505,9 @@ int main(int argc, char **argv) const tEdRenderResult StuntResult = WaitForResult(Service, ullStuntRequest); assert(StuntResult.Tag.eResult == ROLLER_ED_RESULT_OK); + assert(!StuntResult.bHasTowerSnapshot); + assert(g_uiTowerCountQueryCount.load() == 1u); + assert(g_uiTowerQueryCount.load() == 2u); assert(g_uiStuntTickCount.load() == 3u); assert(g_uiStuntTicksAtLastRender.load() == 3u); } @@ -466,6 +527,10 @@ int main(int argc, char **argv) assert(g_sLoadedAssetRoot == "original-document-assets"); assert(!QFile::exists(sEditedTemporaryTrack)); assert(EditedResult.Tag.eResult == ROLLER_ED_RESULT_OK); + assert(EditedResult.bHasTowerSnapshot); + assert(EditedResult.Towers.size() == 2u); + assert(EditedResult.Towers[0].fWorldPosition[0] + != GoodResult.Towers[0].fWorldPosition[0]); assert(Document.ApplyResult(EditedResult)); assert(Document.CanExport()); @@ -479,6 +544,8 @@ int main(int argc, char **argv) const QString sFailedTemporaryTrack = QString::fromLocal8Bit(g_sLoadedTrackPath.c_str()); assert(FailedResult.Tag.eResult == ROLLER_ED_RESULT_LOAD_FAILED); + assert(FailedResult.bHasTowerSnapshot); + assert(FailedResult.Towers.empty()); assert(FailedResult.sErrorText == "copied load error"); assert(g_sError == "a later facade call replaced the error buffer"); assert(!QFile::exists(sFailedTemporaryTrack)); @@ -532,6 +599,8 @@ int main(int argc, char **argv) WaitForResult(Service, ullEmptyTabBRequest); assert(EmptyTabBResult.Tag.eResult == ROLLER_ED_RESULT_OK); assert(EmptyTabBResult.bSceneEmpty); + assert(EmptyTabBResult.bHasTowerSnapshot); + assert(EmptyTabBResult.Towers.empty()); assert(g_uiSceneState == ROLLER_ED_SCENE_EMPTY); assert(TabB.ApplyResult(EmptyTabBResult)); assert(TabB.GetDisplayState() == eEdFrameDisplayState::PLACEHOLDER); diff --git a/tests/test_e7_s6_view_from_tower.py b/tests/test_e7_s6_view_from_tower.py new file mode 100644 index 00000000..35f92f30 --- /dev/null +++ b/tests/test_e7_s6_view_from_tower.py @@ -0,0 +1,140 @@ +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +EDITOR = ROOT / "TrackEditor" + + +def read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def function_body(text: str, signature: str) -> str: + start = text.index(signature) + brace = text.index("{", start) + depth = 0 + for position in range(brace, len(text)): + if text[position] == "{": + depth += 1 + elif text[position] == "}": + depth -= 1 + if depth == 0: + return text[start : position + 1] + raise AssertionError(f"unterminated function: {signature}") + + +class TowerDockTests(unittest.TestCase): + def test_the_dock_has_a_view_from_tower_button(self) -> None: + tree = ET.parse(EDITOR / "EditTowerWidget.ui") + button = tree.find(".//widget[@name='pbViewFromTower']") + self.assertIsNotNone(button) + self.assertEqual( + button.findtext("property[@name='text']/string"), + "View from tower", + ) + + widget = read(EDITOR / "EditTowerWidget.cpp") + self.assertIn( + "connect(pbViewFromTower, &QPushButton::clicked,", widget + ) + self.assertIn("pbViewFromTower->setEnabled(bHasTower);", widget) + + def test_the_selected_tower_chunk_is_sent_to_the_current_preview(self) -> None: + widget = read(EDITOR / "EditTowerWidget.cpp") + body = function_body( + widget, "void CEditTowerWidget::ViewFromTowerClicked()" + ) + self.assertIn("CEditorTowerModel::IsTower(", body) + self.assertIn("g_pMainWindow->GetCurrentPreview()", body) + self.assertIn("pPreview->ViewFromTower(iFrom);", body) + self.assertNotIn("RollerEd_", body) + + +class WorkerSnapshotTests(unittest.TestCase): + def test_load_results_own_the_committed_tower_table(self) -> None: + queue = read(EDITOR / "EditorRenderQueue.h") + self.assertIn("std::vector Towers;", queue) + self.assertIn("bool bHasTowerSnapshot = false;", queue) + + service = read(EDITOR / "EditorRenderService.cpp") + query = function_body( + service, "bool QueryTowers(tEdRenderResult &Result)" + ) + for contract in ( + 'AssertWorkerThread("RollerEd_QueryTowerCount")', + "RollerEd_QueryTowerCount(&uiTowerCount)", + 'AssertWorkerThread("RollerEd_QueryTower")', + "RollerEd_QueryTower(uiIndex, &Info)", + "ROLLER_ED_TOWER_INFO_VERSION", + ): + self.assertIn(contract, query) + + process = function_body( + service, + "tEdRenderResult ProcessRequest(const tEdRenderRequest &Request)", + ) + self.assertIn("Result.bHasTowerSnapshot = bLoadCommand;", process) + self.assertIn("if (bLoadCommand && !QueryTowers(Result))", process) + self.assertLess( + process.index("Sizes.uiSceneState != ROLLER_ED_SCENE_READY"), + process.index("if (bLoadCommand && !QueryTowers(Result))"), + ) + + def test_only_an_accepted_result_replaces_the_preview_table(self) -> None: + preview = read(EDITOR / "TrackPreview.cpp") + completed = function_body( + preview, + "void CTrackPreview::OnRenderCompleted(const tEdRenderResult &Result)", + ) + self.assertLess( + completed.index("m_FrameState.ApplyResult(Result)"), + completed.index("m_Towers = Result.Towers"), + ) + self.assertIn("if (Result.bHasTowerSnapshot)", completed) + + +class CameraPlacementTests(unittest.TestCase): + def test_the_cached_position_and_anchor_drive_the_existing_controller(self) -> None: + preview = read(EDITOR / "TrackPreview.cpp") + body = function_body(preview, "bool CTrackPreview::ViewFromTower") + for contract in ( + "Tower.uiChunkId", + "Tower.fWorldPosition", + "Tower.fAnchorPosition", + "CEditorCameraController::CalculateLookAtOrientation(", + "m_CameraController.SetPosition(", + "m_CameraController.SetOrientation(", + "m_CameraController.ResetMouseTracking();", + "ScheduleCameraRender();", + ): + self.assertIn(contract, body) + self.assertNotIn("RollerEd_", body) + + def test_look_at_math_is_covered_by_the_native_camera_target(self) -> None: + camera = read(EDITOR / "EditorCameraController.cpp") + look_at = function_body( + camera, + "bool CEditorCameraController::CalculateLookAtOrientation", + ) + self.assertIn("std::atan2(fDeltaY, fDeltaX)", look_at) + self.assertIn("std::atan2(fDeltaZ, fHorizontalDistance)", look_at) + + native = read(ROOT / "tests" / "editor_camera_controller_test.cpp") + self.assertIn("TestLookAtOrientationUsesEditorDegrees", native) + self.assertIn("DiagonalUp", native) + self.assertIn("Vertical", native) + + def test_worker_round_trip_is_covered_natively(self) -> None: + native = read(ROOT / "tests" / "editor_render_service_test.cpp") + self.assertIn("RollerEd_QueryTowerCount", native) + self.assertIn("RollerEd_QueryTower(", native) + self.assertIn("GoodResult.bHasTowerSnapshot", native) + self.assertIn("!MeshResult.bHasTowerSnapshot", native) + self.assertIn("EditedResult.Towers[0].fWorldPosition[0]", native) + self.assertIn("EmptyTabBResult.Towers.empty()", native) + + +if __name__ == "__main__": + unittest.main() From 584d793e8e5d82c2b5f1f70843318d83a6fd43cf Mon Sep 17 00:00:00 2001 From: Zizin13 <162662805+Zizin13@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:31:36 -0700 Subject: [PATCH 4/4] sign dialog is disabled when tower exists on chunk --- CMakeLists.txt | 18 ++ TrackEditor/CMakeLists.txt | 2 + TrackEditor/EditSignWidget.cpp | 321 ++++++++++++++----------- TrackEditor/EditSignWidget.h | 7 +- TrackEditor/EditSignWidget.ui | 10 + TrackEditor/EditSurfaceDialog.cpp | 12 +- TrackEditor/EditorSignModel.cpp | 34 +++ TrackEditor/EditorSignModel.h | 24 ++ tests/editor_sign_model_test.cpp | 108 +++++++++ tests/test_e7_s7_sign_tower_lockout.py | 159 ++++++++++++ 10 files changed, 547 insertions(+), 148 deletions(-) create mode 100644 TrackEditor/EditorSignModel.cpp create mode 100644 TrackEditor/EditorSignModel.h create mode 100644 tests/editor_sign_model_test.cpp create mode 100644 tests/test_e7_s7_sign_tower_lockout.py diff --git a/CMakeLists.txt b/CMakeLists.txt index b2c9e579..2ce56214 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,6 +65,18 @@ if(BUILD_TESTING) add_test(NAME trackeditor-e7-s5-tower-model COMMAND trackeditor-e7-s5-tower-model-test) + add_executable(trackeditor-e7-s7-sign-model-test + tests/editor_sign_model_test.cpp + TrackEditor/EditorSignModel.cpp + TrackEditor/EditorSignModel.h) + target_compile_features(trackeditor-e7-s7-sign-model-test PRIVATE cxx_std_17) + target_include_directories(trackeditor-e7-s7-sign-model-test PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/TrackEditor") + target_link_libraries(trackeditor-e7-s7-sign-model-test PRIVATE + TrackEditor::track-model) + add_test(NAME trackeditor-e7-s7-sign-model + COMMAND trackeditor-e7-s7-sign-model-test) + find_package(Python3 COMPONENTS Interpreter QUIET) if(Python3_Interpreter_FOUND) add_test( @@ -227,6 +239,11 @@ if(BUILD_TESTING) COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_e7_s6_view_from_tower.py" -v) + add_test( + NAME trackeditor-e7-s7-sign-tower-lockout-contract + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_e7_s7_sign_tower_lockout.py" + -v) add_test( NAME trackeditor-graphics-settings-contract COMMAND "${Python3_EXECUTABLE}" @@ -418,6 +435,7 @@ if(BUILD_TESTING) trackeditor-e3-s5a-track-model-test trackeditor-e3-s5b-track-assets-test trackeditor-e7-s5-tower-model-test + trackeditor-e7-s7-sign-model-test trackeditor-e3-s1-frame-delivery-test trackeditor-e3-s1-render-service-test trackeditor-e3a-s2-overlay-settings-test diff --git a/TrackEditor/CMakeLists.txt b/TrackEditor/CMakeLists.txt index 3f037a56..d95b3068 100644 --- a/TrackEditor/CMakeLists.txt +++ b/TrackEditor/CMakeLists.txt @@ -55,6 +55,8 @@ set(trackeditor_sources EditorOverlaySettings.h EditorReferenceMesh.cpp EditorReferenceMesh.h + EditorSignModel.cpp + EditorSignModel.h EditorTowerModel.cpp EditorTowerModel.h EditorRenderQueue.cpp diff --git a/TrackEditor/EditSignWidget.cpp b/TrackEditor/EditSignWidget.cpp index ddb4cd0e..bd8b76ec 100644 --- a/TrackEditor/EditSignWidget.cpp +++ b/TrackEditor/EditSignWidget.cpp @@ -5,6 +5,7 @@ #include "MainWindow.h" #include "QtHelpers.h" #include "EditSurfaceDialog.h" +#include "EditorSignModel.h" #include "SignType.h" //------------------------------------------------------------------------------------------------- #if defined(_DEBUG) && defined(IS_WINDOWS) @@ -22,6 +23,8 @@ CEditSignWidget::CEditSignWidget(QWidget *pParent) } lblUnk->hide(); leUnk->hide(); + lblTowerDisabled->setStyleSheet("QLabel { color : red; }"); + lblTowerDisabled->hide(); connect(g_pMainWindow, &CMainWindow::UpdateGeometrySelectionSig, this, &CEditSignWidget::UpdateGeometrySelection); @@ -44,183 +47,210 @@ CEditSignWidget::~CEditSignWidget() //------------------------------------------------------------------------------------------------- +bool CEditSignWidget::GetSelection(CTrack *&pTrackOut, + int &iFromOut, int &iToOut) const +{ + pTrackOut = g_pMainWindow->GetCurrentTrack(); + iFromOut = g_pMainWindow->GetSelFrom(); + iToOut = g_pMainWindow->GetSelTo(); + return pTrackOut && iFromOut >= 0 && iToOut >= iFromOut + && iToOut < static_cast(pTrackOut->m_chunkAy.size()); +} + +//------------------------------------------------------------------------------------------------- + +void CEditSignWidget::CommitEdit(int iEdited, const QString &sDescription) +{ + if (iEdited == 0) + return; + g_pMainWindow->SaveHistory(sDescription); + g_pMainWindow->UpdateWindow(); +} + +//------------------------------------------------------------------------------------------------- + void CEditSignWidget::UpdateGeometrySelection(int iFrom, int iTo) { (void)(iTo); - if (!g_pMainWindow->GetCurrentTrack() || iFrom >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size()) + CTrack *pTrack = g_pMainWindow->GetCurrentTrack(); + if (!pTrack || iFrom < 0 + || iFrom >= static_cast(pTrack->m_chunkAy.size())) return; - - BLOCK_SIG_AND_DO(dsbYaw , setValue(g_pMainWindow->GetCurrentTrack()->m_chunkAy[iFrom].dSignYaw)); - BLOCK_SIG_AND_DO(dsbPitch , setValue(g_pMainWindow->GetCurrentTrack()->m_chunkAy[iFrom].dSignPitch)); - BLOCK_SIG_AND_DO(dsbRoll , setValue(g_pMainWindow->GetCurrentTrack()->m_chunkAy[iFrom].dSignRoll)); - BLOCK_SIG_AND_DO(sbHOffset , setValue(g_pMainWindow->GetCurrentTrack()->m_chunkAy[iFrom].iSignHorizOffset)); - BLOCK_SIG_AND_DO(sbVOffset , setValue(g_pMainWindow->GetCurrentTrack()->m_chunkAy[iFrom].iSignVertOffset)); - BLOCK_SIG_AND_DO(cbType , setCurrentIndex(cbType->findData(g_pMainWindow->GetCurrentTrack()->m_chunkAy[iFrom].iSignType))); - BLOCK_SIG_AND_DO(leUnk , setText(QString::number(g_pMainWindow->GetCurrentTrack()->m_chunkAy[iFrom].iSignType))); - - bool bChunkHasSign = g_pMainWindow->GetCurrentTrack()->m_chunkAy[iFrom].iSignType != -1; - bool bCanHaveTexture = g_pMainWindow->GetCurrentTrack()->m_chunkAy[iFrom].iSignType < g_signAyCount - && g_signAy[g_pMainWindow->GetCurrentTrack()->m_chunkAy[iFrom].iSignType].bCanHaveTexture; - bool bBillboarded = g_signAy[g_pMainWindow->GetCurrentTrack()->m_chunkAy[iFrom].iSignType].bBillboarded; - dsbYaw ->setEnabled(bChunkHasSign && !bBillboarded); - dsbPitch ->setEnabled(bChunkHasSign); - dsbRoll ->setEnabled(bChunkHasSign); - sbHOffset ->setEnabled(bChunkHasSign); - sbVOffset ->setEnabled(bChunkHasSign); - cbType ->setEnabled(bChunkHasSign); - pbEdit ->setEnabled(bChunkHasSign && bCanHaveTexture); - lblYaw ->setEnabled(bChunkHasSign); - lblPitch ->setEnabled(bChunkHasSign); - lblRoll ->setEnabled(bChunkHasSign); + + const tGeometryChunk &Chunk = pTrack->m_chunkAy[iFrom]; + const int iSignType = Chunk.iSignType; + const bool bHasTower = CEditorSignModel::IsTower(iSignType); + // Preserve the legacy treatment of non-tower raw values; E7-S7 only splits + // the >= 256 tower namespace away from the sign controls. + const bool bChunkHasSign = iSignType != -1 && !bHasTower; + const bool bKnownSign = CEditorSignModel::IsKnownSignIndex( + iSignType, g_signAyCount); + const bool bCanHaveTexture = bKnownSign + && g_signAy[iSignType].bCanHaveTexture; + // E7-S7 regression: the table may only be indexed after both bounds have + // been established. Tower values and the empty -1 value never reach it. + const bool bBillboarded = iSignType >= 0 + && iSignType < g_signAyCount + && g_signAy[iSignType].bBillboarded; + + BLOCK_SIG_AND_DO(dsbYaw, setValue(Chunk.dSignYaw)); + BLOCK_SIG_AND_DO(dsbPitch, setValue(Chunk.dSignPitch)); + BLOCK_SIG_AND_DO(dsbRoll, setValue(Chunk.dSignRoll)); + BLOCK_SIG_AND_DO(sbHOffset, setValue(Chunk.iSignHorizOffset)); + BLOCK_SIG_AND_DO(sbVOffset, setValue(Chunk.iSignVertOffset)); + // Keep the last actual sign selected while a tower owns this shared field. + // In particular, findData(>= 256) must not replace cbType with index -1. + if (!bHasTower) { + BLOCK_SIG_AND_DO(cbType, setCurrentIndex(cbType->findData(iSignType))); + BLOCK_SIG_AND_DO(leUnk, setText(QString::number(iSignType))); + } + + dsbYaw->setEnabled(bChunkHasSign && !bBillboarded); + dsbPitch->setEnabled(bChunkHasSign); + dsbRoll->setEnabled(bChunkHasSign); + sbHOffset->setEnabled(bChunkHasSign); + sbVOffset->setEnabled(bChunkHasSign); + cbType->setEnabled(bChunkHasSign); + leUnk->setEnabled(bChunkHasSign); + pbEdit->setEnabled(bChunkHasSign && bCanHaveTexture); + pbSign->setEnabled(!bHasTower); + lblTex->setEnabled(bChunkHasSign); + lblYaw->setEnabled(bChunkHasSign); + lblPitch->setEnabled(bChunkHasSign); + lblRoll->setEnabled(bChunkHasSign); lblHOffset->setEnabled(bChunkHasSign); lblVOffset->setEnabled(bChunkHasSign); - lblType ->setEnabled(bChunkHasSign); - pbSign ->setText(bChunkHasSign ? "Delete Sign" : "Add Sign"); + lblType->setEnabled(bChunkHasSign); + pbSign->setText(bChunkHasSign ? "Delete Sign" : "Add Sign"); - bool bUnk = g_pMainWindow->GetCurrentTrack()->m_chunkAy[iFrom].iSignType > 255; + lblTowerDisabled->setVisible(bHasTower); + lblTowerDisabled->setEnabled(true); + const bool bUnk = bChunkHasSign && iSignType > 255; leUnk->setVisible(bUnk); lblUnk->setVisible(bUnk); - - QtHelpers::UpdateTextures(lblTex, NULL, g_pMainWindow->GetCurrentTrack()->m_assets.GetSignTexture(), g_pMainWindow->GetCurrentTrack()->m_assets.GetPalette(), g_pMainWindow->GetCurrentTrack()->m_chunkAy[iFrom].iSignTexture); + + QtHelpers::UpdateTextures(lblTex, NULL, pTrack->m_assets.GetSignTexture(), + pTrack->m_assets.GetPalette(), Chunk.iSignTexture); } //------------------------------------------------------------------------------------------------- void CEditSignWidget::YawChanged(double dVal) { - int iFrom = g_pMainWindow->GetSelFrom(); - int iTo = g_pMainWindow->GetSelTo(); - - if (!g_pMainWindow->GetCurrentTrack() - || iFrom >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size() - || iTo >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size()) + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (!GetSelection(pTrack, iFrom, iTo)) return; - - for (int i = iFrom; i <= iTo; ++i) { - g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].dSignYaw = dVal; - } - - g_pMainWindow->SaveHistory("Changed sign yaw"); - g_pMainWindow->UpdateWindow(); + CommitEdit(CEditorSignModel::ApplyToRange( + pTrack->m_chunkAy, iFrom, iTo, + [dVal](tGeometryChunk &Chunk) { Chunk.dSignYaw = dVal; }), + "Changed sign yaw"); } //------------------------------------------------------------------------------------------------- void CEditSignWidget::PitchChanged(double dVal) { - int iFrom = g_pMainWindow->GetSelFrom(); - int iTo = g_pMainWindow->GetSelTo(); - - if (!g_pMainWindow->GetCurrentTrack() - || iFrom >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size() - || iTo >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size()) + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (!GetSelection(pTrack, iFrom, iTo)) return; - - for (int i = iFrom; i <= iTo; ++i) { - g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].dSignPitch = dVal; - } - - g_pMainWindow->SaveHistory("Changed sign pitch"); - g_pMainWindow->UpdateWindow(); + CommitEdit(CEditorSignModel::ApplyToRange( + pTrack->m_chunkAy, iFrom, iTo, + [dVal](tGeometryChunk &Chunk) { Chunk.dSignPitch = dVal; }), + "Changed sign pitch"); } //------------------------------------------------------------------------------------------------- void CEditSignWidget::RollChanged(double dVal) { - int iFrom = g_pMainWindow->GetSelFrom(); - int iTo = g_pMainWindow->GetSelTo(); - - if (!g_pMainWindow->GetCurrentTrack() - || iFrom >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size() - || iTo >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size()) + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (!GetSelection(pTrack, iFrom, iTo)) return; - - for (int i = iFrom; i <= iTo; ++i) { - g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].dSignRoll = dVal; - } - - g_pMainWindow->SaveHistory("Changed sign roll"); - g_pMainWindow->UpdateWindow(); + CommitEdit(CEditorSignModel::ApplyToRange( + pTrack->m_chunkAy, iFrom, iTo, + [dVal](tGeometryChunk &Chunk) { Chunk.dSignRoll = dVal; }), + "Changed sign roll"); } //------------------------------------------------------------------------------------------------- void CEditSignWidget::HOffsetChanged(int iVal) { - int iFrom = g_pMainWindow->GetSelFrom(); - int iTo = g_pMainWindow->GetSelTo(); - - if (!g_pMainWindow->GetCurrentTrack() - || iFrom >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size() - || iTo >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size()) + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (!GetSelection(pTrack, iFrom, iTo)) return; - - for (int i = iFrom; i <= iTo; ++i) { - g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].iSignHorizOffset = iVal; - } - - g_pMainWindow->SaveHistory("Changed sign horiz offset"); - g_pMainWindow->UpdateWindow(); + CommitEdit(CEditorSignModel::ApplyToRange( + pTrack->m_chunkAy, iFrom, iTo, + [iVal](tGeometryChunk &Chunk) { Chunk.iSignHorizOffset = iVal; }), + "Changed sign horiz offset"); } //------------------------------------------------------------------------------------------------- void CEditSignWidget::VOffsetChanged(int iVal) { - int iFrom = g_pMainWindow->GetSelFrom(); - int iTo = g_pMainWindow->GetSelTo(); - - if (!g_pMainWindow->GetCurrentTrack() - || iFrom >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size() - || iTo >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size()) + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (!GetSelection(pTrack, iFrom, iTo)) return; - - for (int i = iFrom; i <= iTo; ++i) { - g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].iSignVertOffset = iVal; - } - - g_pMainWindow->SaveHistory("Changed sign vert offset"); - g_pMainWindow->UpdateWindow(); + CommitEdit(CEditorSignModel::ApplyToRange( + pTrack->m_chunkAy, iFrom, iTo, + [iVal](tGeometryChunk &Chunk) { Chunk.iSignVertOffset = iVal; }), + "Changed sign vert offset"); } //------------------------------------------------------------------------------------------------- void CEditSignWidget::TypeChanged(int iIndex) { - int iFrom = g_pMainWindow->GetSelFrom(); - int iTo = g_pMainWindow->GetSelTo(); - - if (!g_pMainWindow->GetCurrentTrack() - || iFrom >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size() - || iTo >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size()) + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (iIndex < 0 || !GetSelection(pTrack, iFrom, iTo)) + return; + if (CEditorSignModel::IsTower(pTrack->m_chunkAy[iFrom].iSignType)) return; - for (int i = iFrom; i <= iTo; ++i) { - g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].iSignType = cbType->itemData(iIndex).toInt(); - if (cbType->itemData(iIndex).toInt() < g_signAyCount - && !g_signAy[cbType->itemData(iIndex).toInt()].bCanHaveTexture) { - g_pMainWindow->GetCurrentTrack()->m_chunkAy[iFrom].iSignTexture = -1; - } else { - g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].iSignTexture = SURFACE_FLAG_APPLY_TEXTURE; - } - } - - g_pMainWindow->SaveHistory("Changed sign type"); - g_pMainWindow->UpdateWindow(); + const int iSignType = cbType->itemData(iIndex).toInt(); + if (!CEditorSignModel::IsSign(iSignType)) + return; + const bool bCanHaveTexture = + CEditorSignModel::IsKnownSignIndex(iSignType, g_signAyCount) + && g_signAy[iSignType].bCanHaveTexture; + const int iEdited = CEditorSignModel::ApplyToRange( + pTrack->m_chunkAy, iFrom, iTo, + [iSignType, bCanHaveTexture](tGeometryChunk &Chunk) { + Chunk.iSignType = iSignType; + if (bCanHaveTexture) + Chunk.iSignTexture = SURFACE_FLAG_APPLY_TEXTURE; + }); + // Preserve the old non-textured-type behavior: only the displayed chunk's + // texture field was cleared, while textured types initialized the range. + if (iEdited != 0 && !bCanHaveTexture) + pTrack->m_chunkAy[iFrom].iSignTexture = -1; + CommitEdit(iEdited, "Changed sign type"); } //------------------------------------------------------------------------------------------------- void CEditSignWidget::EditClicked() { - int iFrom = g_pMainWindow->GetSelFrom(); - int iTo = g_pMainWindow->GetSelTo(); - - if (!g_pMainWindow->GetCurrentTrack() - || iFrom >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size() - || iTo >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size()) + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (!GetSelection(pTrack, iFrom, iTo) + || CEditorSignModel::IsTower(pTrack->m_chunkAy[iFrom].iSignType)) { return; + } CEditSurfaceDialog dlg(this, eSurfaceField::SURFACE_SIGN); dlg.exec(); @@ -233,44 +263,43 @@ void CEditSignWidget::EditClicked() void CEditSignWidget::SignClicked() { - int iFrom = g_pMainWindow->GetSelFrom(); - int iTo = g_pMainWindow->GetSelTo(); - - if (!g_pMainWindow->GetCurrentTrack() - || iFrom >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size() - || iTo >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size()) + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (!GetSelection(pTrack, iFrom, iTo) + || CEditorSignModel::IsTower(pTrack->m_chunkAy[iFrom].iSignType)) { return; - - bool bHasSign = g_pMainWindow->GetCurrentTrack()->m_chunkAy[iFrom].iSignType != -1; - for (int i = iFrom; i <= iTo; ++i) { - g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].iSignType = bHasSign ? -1 : 9; //default sign type of balloon - if (!bHasSign) { - g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].iSignTexture = SURFACE_FLAG_APPLY_TEXTURE; - } } - g_pMainWindow->SaveHistory(bHasSign ? "Removed sign" : "Added sign"); - g_pMainWindow->UpdateWindow(); + const bool bHasSign = pTrack->m_chunkAy[iFrom].iSignType != -1; + CommitEdit(CEditorSignModel::ApplyToRange( + pTrack->m_chunkAy, iFrom, iTo, + [bHasSign](tGeometryChunk &Chunk) { + Chunk.iSignType = bHasSign ? -1 : 9; // Balloon is the default sign. + if (!bHasSign) + Chunk.iSignTexture = SURFACE_FLAG_APPLY_TEXTURE; + }), bHasSign ? "Removed sign" : "Added sign"); } //------------------------------------------------------------------------------------------------- void CEditSignWidget::UnkChanged(const QString &sText) { - int iFrom = g_pMainWindow->GetSelFrom(); - int iTo = g_pMainWindow->GetSelTo(); - - if (!g_pMainWindow->GetCurrentTrack() - || iFrom >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size() - || iTo >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size()) + bool bOk = false; + const int iSignType = sText.toInt(&bOk); + if (!bOk || !CEditorSignModel::IsSign(iSignType)) return; - for (int i = iFrom; i <= iTo; ++i) { - g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].iSignType = sText.toInt(); - } - - g_pMainWindow->SaveHistory("Changed unk sign value"); - g_pMainWindow->UpdateWindow(); + CTrack *pTrack = nullptr; + int iFrom = 0; + int iTo = 0; + if (!GetSelection(pTrack, iFrom, iTo)) + return; + CommitEdit(CEditorSignModel::ApplyToRange( + pTrack->m_chunkAy, iFrom, iTo, + [iSignType](tGeometryChunk &Chunk) { + Chunk.iSignType = iSignType; + }), "Changed unk sign value"); } -//------------------------------------------------------------------------------------------------- \ No newline at end of file +//------------------------------------------------------------------------------------------------- diff --git a/TrackEditor/EditSignWidget.h b/TrackEditor/EditSignWidget.h index 065a66b1..103a6de8 100644 --- a/TrackEditor/EditSignWidget.h +++ b/TrackEditor/EditSignWidget.h @@ -4,6 +4,7 @@ #include "ui_EditSignWidget.h" //------------------------------------------------------------------------------------------------- class CEditSignWidgetPrivate; +class CTrack; //------------------------------------------------------------------------------------------------- class CEditSignWidget : public QWidget, private Ui::EditSignWidget @@ -25,7 +26,11 @@ protected slots: void EditClicked(); void SignClicked(); void UnkChanged(const QString &sText); + +private: + bool GetSelection(CTrack *&pTrackOut, int &iFromOut, int &iToOut) const; + void CommitEdit(int iEdited, const QString &sDescription); }; //------------------------------------------------------------------------------------------------- -#endif \ No newline at end of file +#endif diff --git a/TrackEditor/EditSignWidget.ui b/TrackEditor/EditSignWidget.ui index f025b342..4a1eb23b 100644 --- a/TrackEditor/EditSignWidget.ui +++ b/TrackEditor/EditSignWidget.ui @@ -64,6 +64,16 @@ + + + + Tower exists on this chunk + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + diff --git a/TrackEditor/EditSurfaceDialog.cpp b/TrackEditor/EditSurfaceDialog.cpp index d99de809..ec1382fb 100644 --- a/TrackEditor/EditSurfaceDialog.cpp +++ b/TrackEditor/EditSurfaceDialog.cpp @@ -1,5 +1,6 @@ #include "TrackEditor.h" #include "EditSurfaceDialog.h" +#include "EditorSignModel.h" #include "Texture.h" #include "Palette.h" #include "TilePicker.h" @@ -537,6 +538,7 @@ void CEditSurfaceDialog::UpdateValueHelper(uint32 uiFlag, bool bChecked) int &CEditSurfaceDialog::GetValue(int i) { if (!g_pMainWindow->GetCurrentTrack() + || i < 0 || i >= (int)g_pMainWindow->GetCurrentTrack()->m_chunkAy.size()) return (int&)m_uiSignedBitValue; @@ -553,7 +555,15 @@ int &CEditSurfaceDialog::GetValue(int i) case eSurfaceField::SURFACE_RLOWALL: return g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].iRLOuterWallType; break; case eSurfaceField::SURFACE_RUOWALL: return g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].iRUOuterWallType; break; case eSurfaceField::SURFACE_ENVFLOOR: return g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].iEnvironmentFloorType; break; - case eSurfaceField::SURFACE_SIGN: return g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].iSignTexture; break; + case eSurfaceField::SURFACE_SIGN: + // The texture editor is opened by EditSignWidget and also writes a + // range. Route tower entries to scratch storage so those shared sign + // columns remain byte-identical in a mixed selection. E7-S7. + if (CEditorSignModel::IsTower( + g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].iSignType)) { + return (int&)m_uiSignedBitValue; + } + return g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].iSignTexture; default: return g_pMainWindow->GetCurrentTrack()->m_chunkAy[i].iEnvironmentFloorType; } } diff --git a/TrackEditor/EditorSignModel.cpp b/TrackEditor/EditorSignModel.cpp new file mode 100644 index 00000000..3934892b --- /dev/null +++ b/TrackEditor/EditorSignModel.cpp @@ -0,0 +1,34 @@ +#include "EditorSignModel.h" + +bool CEditorSignModel::IsTower(int iSignType) +{ + return iSignType >= TOWER_TYPE_BASE; +} + +bool CEditorSignModel::IsSign(int iSignType) +{ + return iSignType >= 0 && iSignType < TOWER_TYPE_BASE; +} + +bool CEditorSignModel::IsKnownSignIndex(int iSignType, int iSignCount) +{ + return iSignType >= 0 && iSignType < iSignCount; +} + +int CEditorSignModel::ApplyToRange(CChunkAy &chunkAy, int iFrom, int iTo, + const tEditOperation &Operation) +{ + if (!Operation || iFrom < 0 || iTo < iFrom + || iTo >= static_cast(chunkAy.size())) { + return 0; + } + + int iEdited = 0; + for (int i = iFrom; i <= iTo; ++i) { + if (IsTower(chunkAy[i].iSignType)) + continue; + Operation(chunkAy[i]); + ++iEdited; + } + return iEdited; +} diff --git a/TrackEditor/EditorSignModel.h b/TrackEditor/EditorSignModel.h new file mode 100644 index 00000000..a9ca7fb9 --- /dev/null +++ b/TrackEditor/EditorSignModel.h @@ -0,0 +1,24 @@ +#ifndef TRACKEDITOR_EDITORSIGNMODEL_H +#define TRACKEDITOR_EDITORSIGNMODEL_H + +#include "TrackModel.h" + +#include + +// E7-S7. Sign and tower data share the same serialized columns, so every +// sign-side range edit goes through this gate rather than relying on a +// disabled widget to protect tower chunks. +class CEditorSignModel +{ +public: + static constexpr int TOWER_TYPE_BASE = 256; + using tEditOperation = std::function; + + static bool IsTower(int iSignType); + static bool IsSign(int iSignType); + static bool IsKnownSignIndex(int iSignType, int iSignCount); + static int ApplyToRange(CChunkAy &chunkAy, int iFrom, int iTo, + const tEditOperation &Operation); +}; + +#endif diff --git a/tests/editor_sign_model_test.cpp b/tests/editor_sign_model_test.cpp new file mode 100644 index 00000000..4c47e00f --- /dev/null +++ b/tests/editor_sign_model_test.cpp @@ -0,0 +1,108 @@ +#include "EditorSignModel.h" + +#include +#include +#include +#include +#include + +#ifdef assert +#undef assert +#endif +#define assert(condition) \ + do { \ + if (!(condition)) { \ + std::cerr << "assertion failed: " #condition << " (" << __FILE__ \ + << ':' << __LINE__ << ")\n"; \ + std::abort(); \ + } \ + } while (false) + +namespace +{ +void TestTypeBoundariesAndSafeTableIndexPredicate() +{ + assert(!CEditorSignModel::IsTower(-1)); + assert(!CEditorSignModel::IsTower(255)); + assert(CEditorSignModel::IsTower(256)); + assert(CEditorSignModel::IsTower(777)); + + assert(!CEditorSignModel::IsSign(-1)); + assert(CEditorSignModel::IsSign(0)); + assert(CEditorSignModel::IsSign(255)); + assert(!CEditorSignModel::IsSign(256)); + + assert(!CEditorSignModel::IsKnownSignIndex(-1, 17)); + assert(CEditorSignModel::IsKnownSignIndex(0, 17)); + assert(CEditorSignModel::IsKnownSignIndex(16, 17)); + assert(!CEditorSignModel::IsKnownSignIndex(17, 17)); + assert(!CEditorSignModel::IsKnownSignIndex(256, 17)); +} + +void TestMixedRangeLeavesEveryTowerByteIdentical() +{ + static_assert(std::is_trivially_copyable::value, + "byte-identity regression requires a trivially copyable chunk"); + + CChunkAy Chunks(4); + for (tGeometryChunk &Chunk : Chunks) + Chunk.Default(); + Chunks[0].iSignType = 9; + Chunks[1].iSignType = 256; + Chunks[2].iSignType = 777; + Chunks[3].iSignType = -1; + Chunks[1].iSignHorizOffset = 123; + Chunks[1].iSignVertOffset = -456; + Chunks[1].dSignYaw = 12.5; + Chunks[1].dSignPitch = 34.5; + Chunks[1].dSignRoll = 56.5; + Chunks[1].iSignTexture = 0x12345678; + Chunks[2].iSignHorizOffset = -321; + Chunks[2].iSignTexture = 0x76543210; + + const tGeometryChunk TowerBefore = Chunks[1]; + const tGeometryChunk RawTowerBefore = Chunks[2]; + const int iEdited = CEditorSignModel::ApplyToRange( + Chunks, 0, 3, [](tGeometryChunk &Chunk) { + Chunk.iSignType = 4; + Chunk.iSignHorizOffset = 999; + Chunk.iSignVertOffset = 888; + Chunk.dSignYaw = 111.0; + Chunk.dSignPitch = 222.0; + Chunk.dSignRoll = 333.0; + Chunk.iSignTexture = 0x0badcafe; + }); + + assert(iEdited == 2); + assert(Chunks[0].iSignType == 4); + assert(Chunks[3].iSignType == 4); + assert(std::memcmp(&Chunks[1], &TowerBefore, sizeof(TowerBefore)) == 0); + assert(std::memcmp(&Chunks[2], &RawTowerBefore, + sizeof(RawTowerBefore)) == 0); +} + +void TestInvalidRangesWriteNothing() +{ + CChunkAy Chunks(1); + Chunks[0].Default(); + Chunks[0].iSignType = 9; + const tGeometryChunk Before = Chunks[0]; + const CEditorSignModel::tEditOperation Edit = [](tGeometryChunk &Chunk) { + Chunk.iSignType = 2; + }; + + assert(CEditorSignModel::ApplyToRange(Chunks, -1, 0, Edit) == 0); + assert(CEditorSignModel::ApplyToRange(Chunks, 0, 1, Edit) == 0); + assert(CEditorSignModel::ApplyToRange(Chunks, 0, 0, {}) == 0); + assert(std::memcmp(&Chunks[0], &Before, sizeof(Before)) == 0); +} +} + +int main() +{ + TestTypeBoundariesAndSafeTableIndexPredicate(); + TestMixedRangeLeavesEveryTowerByteIdentical(); + TestInvalidRangesWriteNothing(); + std::cout << "E7-S7 sign/tower exclusion tests passed\n"; + return 0; +} diff --git a/tests/test_e7_s7_sign_tower_lockout.py b/tests/test_e7_s7_sign_tower_lockout.py new file mode 100644 index 00000000..2cd150e0 --- /dev/null +++ b/tests/test_e7_s7_sign_tower_lockout.py @@ -0,0 +1,159 @@ +import re +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +EDITOR = ROOT / "TrackEditor" + + +def read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def function_body(text: str, signature: str) -> str: + start = text.index(signature) + brace = text.index("{", start) + depth = 0 + for position in range(brace, len(text)): + if text[position] == "{": + depth += 1 + elif text[position] == "}": + depth -= 1 + if depth == 0: + return text[start : position + 1] + raise AssertionError(f"unterminated function: {signature}") + + +class BoundsRegressionTests(unittest.TestCase): + def test_billboard_lookup_is_guarded_on_both_sides(self) -> None: + widget = read(EDITOR / "EditSignWidget.cpp") + update = function_body( + widget, "void CEditSignWidget::UpdateGeometrySelection" + ) + self.assertRegex( + update, + re.compile( + r"bBillboarded\s*=\s*iSignType\s*>=\s*0\s*" + r"&&\s*iSignType\s*<\s*g_signAyCount\s*" + r"&&\s*g_signAy\[iSignType\]\.bBillboarded", + re.S, + ), + ) + self.assertEqual(update.count("g_signAy[iSignType].bBillboarded"), 1) + + def test_safe_index_predicate_covers_empty_unknown_and_tower_values(self) -> None: + native = read(ROOT / "tests" / "editor_sign_model_test.cpp") + for contract in ( + "IsKnownSignIndex(-1, 17)", + "IsKnownSignIndex(16, 17)", + "IsKnownSignIndex(17, 17)", + "IsKnownSignIndex(256, 17)", + ): + self.assertIn(contract, native) + + +class TowerLockoutTests(unittest.TestCase): + def test_the_red_tower_notice_is_present(self) -> None: + tree = ET.parse(EDITOR / "EditSignWidget.ui") + notice = tree.find(".//widget[@name='lblTowerDisabled']") + self.assertIsNotNone(notice) + self.assertEqual( + notice.findtext("property[@name='text']/string"), + "Tower exists on this chunk", + ) + + widget = read(EDITOR / "EditSignWidget.cpp") + self.assertIn( + 'lblTowerDisabled->setStyleSheet("QLabel { color : red; }")', + widget, + ) + self.assertIn("lblTowerDisabled->setVisible(bHasTower);", widget) + self.assertIn("lblTowerDisabled->setEnabled(true);", widget) + + def test_every_sign_control_is_disabled_for_a_tower(self) -> None: + widget = read(EDITOR / "EditSignWidget.cpp") + update = function_body( + widget, "void CEditSignWidget::UpdateGeometrySelection" + ) + for control in ( + "dsbYaw", + "dsbPitch", + "dsbRoll", + "sbHOffset", + "sbVOffset", + "cbType", + "leUnk", + "pbEdit", + "lblTex", + ): + self.assertRegex(update, rf"{control}->setEnabled\([^;]*bChunkHasSign") + self.assertIn("pbSign->setEnabled(!bHasTower);", update) + + def test_selecting_a_tower_does_not_mutate_the_type_combo(self) -> None: + widget = read(EDITOR / "EditSignWidget.cpp") + update = function_body( + widget, "void CEditSignWidget::UpdateGeometrySelection" + ) + guard = function_body(update, "if (!bHasTower)") + self.assertIn("BLOCK_SIG_AND_DO(cbType", guard) + self.assertEqual(update.count("BLOCK_SIG_AND_DO(cbType"), 1) + self.assertIn( + "const bool bUnk = bChunkHasSign && iSignType > 255;", update + ) + + +class MixedRangeTests(unittest.TestCase): + def test_all_direct_sign_writes_use_the_shared_tower_exclusion(self) -> None: + widget = read(EDITOR / "EditSignWidget.cpp") + for function in ( + "YawChanged", + "PitchChanged", + "RollChanged", + "HOffsetChanged", + "VOffsetChanged", + "TypeChanged", + "SignClicked", + "UnkChanged", + ): + body = function_body(widget, f"void CEditSignWidget::{function}") + self.assertIn("CEditorSignModel::ApplyToRange(", body) + + model = read(EDITOR / "EditorSignModel.cpp") + apply = function_body(model, "int CEditorSignModel::ApplyToRange") + self.assertIn("if (IsTower(chunkAy[i].iSignType))", apply) + self.assertLess( + apply.index("if (IsTower(chunkAy[i].iSignType))"), + apply.index("Operation(chunkAy[i]);"), + ) + + def test_sign_texture_dialog_routes_towers_to_scratch_storage(self) -> None: + dialog = read(EDITOR / "EditSurfaceDialog.cpp") + get_value = function_body(dialog, "int &CEditSurfaceDialog::GetValue") + sign_case = get_value[get_value.index("case eSurfaceField::SURFACE_SIGN:") :] + self.assertLess( + sign_case.index("CEditorSignModel::IsTower("), + sign_case.index(".iSignTexture"), + ) + self.assertIn("return (int&)m_uiSignedBitValue;", sign_case) + + def test_native_regression_checks_byte_identity(self) -> None: + native = read(ROOT / "tests" / "editor_sign_model_test.cpp") + self.assertIn("TestMixedRangeLeavesEveryTowerByteIdentical", native) + self.assertGreaterEqual(native.count("std::memcmp("), 3) + self.assertIn("Chunks[1].iSignType = 256", native) + self.assertIn("Chunks[2].iSignType = 777", native) + + +class BuildIntegrationTests(unittest.TestCase): + def test_model_and_both_regressions_are_registered(self) -> None: + app_cmake = read(EDITOR / "CMakeLists.txt") + root_cmake = read(ROOT / "CMakeLists.txt") + self.assertIn("EditorSignModel.cpp", app_cmake) + self.assertIn("trackeditor-e7-s7-sign-model-test", root_cmake) + self.assertIn("trackeditor-e7-s7-sign-tower-lockout-contract", root_cmake) + + +if __name__ == "__main__": + unittest.main()