diff --git a/CMakeLists.txt b/CMakeLists.txt
index fa945b5b..2ce56214 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -53,6 +53,30 @@ 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)
+
+ 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(
@@ -200,6 +224,26 @@ 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-e7-s5-edit-towers-contract
+ 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-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}"
@@ -390,6 +434,8 @@ 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-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 719323c5..d95b3068 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,10 @@ set(trackeditor_sources
EditorOverlaySettings.h
EditorReferenceMesh.cpp
EditorReferenceMesh.h
+ EditorSignModel.cpp
+ EditorSignModel.h
+ EditorTowerModel.cpp
+ EditorTowerModel.h
EditorRenderQueue.cpp
EditorRenderQueue.h
EditorRenderService.cpp
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/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/EditTowerWidget.cpp b/TrackEditor/EditTowerWidget.cpp
new file mode 100644
index 00000000..11d05798
--- /dev/null
+++ b/TrackEditor/EditTowerWidget.cpp
@@ -0,0 +1,276 @@
+#include "EditTowerWidget.h"
+
+#include "EditorTowerModel.h"
+#include "MainWindow.h"
+#include "QtHelpers.h"
+#include "Track.h"
+#include "TrackPreview.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(pbViewFromTower, &QPushButton::clicked,
+ this, &CEditTowerWidget::ViewFromTowerClicked);
+ 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);
+ pbViewFromTower->setEnabled(bHasTower);
+
+ 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::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;
+ 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..728419bf
--- /dev/null
+++ b/TrackEditor/EditTowerWidget.h
@@ -0,0 +1,31 @@
+#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 ViewFromTowerClicked();
+ 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..e954a186
--- /dev/null
+++ b/TrackEditor/EditTowerWidget.ui
@@ -0,0 +1,186 @@
+
+
+ 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
+
+
+
+ -
+
+
+ false
+
+
+ View from tower
+
+
+
+ -
+
+
+ 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/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/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/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/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/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 2f6bb2b8..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());
@@ -1106,6 +1116,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 +1242,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);
@@ -1321,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();
@@ -1329,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);
@@ -1345,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);
@@ -1354,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);
@@ -1362,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
@@ -1369,11 +1393,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 +1416,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);
@@ -1464,7 +1492,10 @@ 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());
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..067a3036 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)
@@ -630,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();
@@ -644,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 8416d70c..52326500 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);
@@ -64,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;
@@ -125,6 +127,7 @@ class CTrackPreview : public QWidget
CTrackPreviewPrivate *p;
uint32 m_uiShowModels;
+ uint32 m_uiShowFeatures;
eWhipModel m_carModel;
eShapeSection m_carAILine;
bool m_bMillionPlus;
@@ -139,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/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_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_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/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/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/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_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..a38b28d8
--- /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_joins_the_marker_controls(self) -> None:
+ tree = ET.parse(EDITOR / "DisplaySettings.ui")
+ towers = None
+ 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")
+
+ 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()
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()
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()
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()