diff --git a/code/globalincs/pstypes.h b/code/globalincs/pstypes.h index 90b80c7a8fb..55d94de70b6 100644 --- a/code/globalincs/pstypes.h +++ b/code/globalincs/pstypes.h @@ -364,6 +364,7 @@ const float PI_4 = (PI/4.0f); extern int Fred_running; // Is Fred running, or FreeSpace? +extern int Qtfred_running; // Distinguishes QtFRED from legacy Fred2; Fred_running is set in both, but Qtfred_running only in QtFRED. extern bool running_unittests; const size_t INVALID_SIZE = static_cast(-1); diff --git a/code/mission/missionparse.cpp b/code/mission/missionparse.cpp index 057a62afe4f..73f29262661 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -2426,10 +2426,10 @@ int parse_create_object_sub(p_object *p_objp, bool standalone_ship) // will accept were apparently written out incorrectly with Fred. This Int3() should // trap these instances. #ifndef NDEBUG - if (Fred_running) + if (Fred_running && !Qtfred_running) { std::set default_orders, remaining_orders; - + default_orders = ship_get_default_orders_accepted(&Ship_info[shipp->ship_info_index]); std::set_difference(p_objp->orders_accepted.begin(), p_objp->orders_accepted.end(), default_orders.begin(), default_orders.end(), std::inserter(remaining_orders, remaining_orders.begin())); @@ -2963,7 +2963,8 @@ void resolve_parse_flags(object *objp, flagset &par if ((parse_flags[Mission::Parse_Object_Flags::OF_No_shields]) && (parse_flags[Mission::Parse_Object_Flags::OF_Force_shields_on])) { - Warning(LOCATION, "The parser found a ship with both the \"force-shields-on\" and \"no-shields\" flags; this is inconsistent!"); + if (!Qtfred_running) + Warning(LOCATION, "The parser found a ship with both the \"force-shields-on\" and \"no-shields\" flags; this is inconsistent!"); } if (parse_flags[Mission::Parse_Object_Flags::OF_No_shields]) objp->flags.set(Object::Object_Flags::No_shields); @@ -3458,7 +3459,8 @@ int parse_object(mission *pm, int /*flag*/, p_object *p_objp) || (p_objp->arrival_location == ArrivalLocation::ABOVE_SHIP) || (p_objp->arrival_location == ArrivalLocation::BELOW_SHIP) || (p_objp->arrival_location == ArrivalLocation::TO_LEFT_OF_SHIP) || (p_objp->arrival_location == ArrivalLocation::TO_RIGHT_OF_SHIP) )) { - Warning(LOCATION, "Arrival distance for ship %s cannot be %d. Setting to 1.\n", p_objp->name, p_objp->arrival_distance); + if (!Qtfred_running) + Warning(LOCATION, "Arrival distance for ship %s cannot be %d. Setting to 1.\n", p_objp->name, p_objp->arrival_distance); p_objp->arrival_distance = 1; } } @@ -3483,7 +3485,8 @@ int parse_object(mission *pm, int /*flag*/, p_object *p_objp) stuff_int(&delay); if (delay < 0) { - Warning(LOCATION, "Cannot have arrival delay < 0 on ship %s", p_objp->name); + if (!Qtfred_running) + Warning(LOCATION, "Cannot have arrival delay < 0 on ship %s", p_objp->name); delay = 0; } @@ -3520,7 +3523,8 @@ int parse_object(mission *pm, int /*flag*/, p_object *p_objp) stuff_int(&delay); if (delay < 0) { - Warning(LOCATION, "Cannot have departure delay < 0 (ship %s)", p_objp->name); + if (!Qtfred_running) + Warning(LOCATION, "Cannot have departure delay < 0 (ship %s)", p_objp->name); delay = 0; } @@ -3752,7 +3756,8 @@ int parse_object(mission *pm, int /*flag*/, p_object *p_objp) stuff_int(&p_objp->destroy_before_mission_time); if (p_objp->destroy_before_mission_time < 0) { - Warning(LOCATION, "Cannot set a negative 'destroy before mission' value (ship %s)", p_objp->name); + if (!Qtfred_running) + Warning(LOCATION, "Cannot set a negative 'destroy before mission' value (ship %s)", p_objp->name); p_objp->destroy_before_mission_time = 0; } @@ -3847,7 +3852,8 @@ int parse_object(mission *pm, int /*flag*/, p_object *p_objp) if (optional_string("+Persona Index:")) { stuff_int(&p_objp->persona_index); if (p_objp->persona_index < -1 || p_objp->persona_index >= (int)Personas.size()) { - Warning(LOCATION, "Persona index %d for %s is out of range! Setting to -1.", p_objp->persona_index, p_objp->name); + if (!Qtfred_running) + Warning(LOCATION, "Persona index %d for %s is out of range! Setting to -1.", p_objp->persona_index, p_objp->name); p_objp->persona_index = -1; } } @@ -4915,7 +4921,8 @@ void parse_wing(mission *pm) || (wingp->arrival_location == ArrivalLocation::ABOVE_SHIP) || (wingp->arrival_location == ArrivalLocation::BELOW_SHIP) || (wingp->arrival_location == ArrivalLocation::TO_LEFT_OF_SHIP) || (wingp->arrival_location == ArrivalLocation::TO_RIGHT_OF_SHIP) )) { - Warning(LOCATION, "Arrival distance for wing %s cannot be %d. Setting to 1.\n", wingp->name, wingp->arrival_distance); + if (!Qtfred_running) + Warning(LOCATION, "Arrival distance for wing %s cannot be %d. Setting to 1.\n", wingp->name, wingp->arrival_distance); wingp->arrival_distance = 1; } } @@ -4940,7 +4947,8 @@ void parse_wing(mission *pm) stuff_int(&delay); if (delay < 0) { - Warning(LOCATION, "Cannot have arrival delay < 0 on wing %s", wingp->name); + if (!Qtfred_running) + Warning(LOCATION, "Cannot have arrival delay < 0 on wing %s", wingp->name); delay = 0; } @@ -4977,7 +4985,8 @@ void parse_wing(mission *pm) stuff_int(&delay); if (delay < 0) { - Warning(LOCATION, "Cannot have departure delay < 0 on wing %s", wingp->name); + if (!Qtfred_running) + Warning(LOCATION, "Cannot have departure delay < 0 on wing %s", wingp->name); delay = 0; } @@ -5152,7 +5161,8 @@ void parse_wing(mission *pm) // Goober5000 - if this is a player start object, there shouldn't be a wing arrival delay (Mantis #2678) if ((p_objp->flags[Mission::Parse_Object_Flags::OF_Player_start]) && (wingp->arrival_delay != 0)) { - Warning(LOCATION, "Wing %s specifies an arrival delay of %ds, but it also contains a player. The arrival delay will be reset to 0.", wingp->name, abs(wingp->arrival_delay)); + if (!Qtfred_running) + Warning(LOCATION, "Wing %s specifies an arrival delay of %ds, but it also contains a player. The arrival delay will be reset to 0.", wingp->name, abs(wingp->arrival_delay)); if (!Fred_running && wingp->arrival_delay > 0) { // timestamp has been set, so set it again wingp->arrival_delay = timestamp(0); @@ -5423,7 +5433,9 @@ void post_process_ships_wings() // error checking for custom wings if (strcmp(Starting_wing_names[0], TVT_wing_names[0]) != 0) { - Error(LOCATION, "The first starting wing and the first team-versus-team wing must have the same wing name.\n"); + // In QtFRED this is surfaced via ErrorChecker::checkPlayerWings so the editor can load the mission. + if (!Qtfred_running) + Error(LOCATION, "The first starting wing and the first team-versus-team wing must have the same wing name.\n"); } // set up wing indexes @@ -5610,7 +5622,8 @@ void post_process_ships_wings() for (int i = 1; i < MAX_STARTING_WINGS; i++) { // If there was a wing for this squadron entry, check the last one. If it's empty, we found a mistake, so move the wing names over. if (Squadron_wing_names_found[i] && !Squadron_wing_names_found[i - 1]) { - Warning(LOCATION, "Squadron wings are not in the correct order and may cause wings to disappear in multi.\n\nEither wing %s should exist or the %s entry needs to come before it in the list.\n\nPlease go back and fix the mission.", Squadron_wing_names[i - 1], Squadron_wing_names[i]); + if (!Qtfred_running) + Warning(LOCATION, "Squadron wings are not in the correct order and may cause wings to disappear in multi.\n\nEither wing %s should exist or the %s entry needs to come before it in the list.\n\nPlease go back and fix the mission.", Squadron_wing_names[i - 1], Squadron_wing_names[i]); char temp_chars[NAME_LENGTH]; strcpy_s(temp_chars, Squadron_wing_names[i - 1]); strcpy_s(Squadron_wing_names[i - 1], Squadron_wing_names[i]); @@ -5648,7 +5661,8 @@ void parse_event(mission *pm) // sanity check on the repeat count variable // _argv[-1] - negative repeat count is now legal; means repeat indefinitely. if ( event->repeat_count == 0 ){ - Warning(LOCATION, "Repeat count for mission event %s is 0.\nMust be >= 1 or negative! Setting to 1.", event->name.c_str() ); + if (!Qtfred_running) + Warning(LOCATION, "Repeat count for mission event %s is 0.\nMust be >= 1 or negative! Setting to 1.", event->name.c_str() ); event->repeat_count = 1; } } @@ -5665,7 +5679,8 @@ void parse_event(mission *pm) // sanity check on the trigger count variable // negative trigger count is also legal if ( event->trigger_count == 0 ){ - Warning(LOCATION, "Trigger count for mission event %s is 0.\nMust be >= 1 or negative! Setting to 1.", event->name.c_str() ); + if (!Qtfred_running) + Warning(LOCATION, "Trigger count for mission event %s is 0.\nMust be >= 1 or negative! Setting to 1.", event->name.c_str() ); event->trigger_count = 1; } } @@ -6021,7 +6036,8 @@ void parse_reinforcement(mission *pm) stuff_int(&delay); if (delay < 0) { - Warning(LOCATION, "Cannot have arrival delay < 0 on reinforcement %s", ptr->name); + if (!Qtfred_running) + Warning(LOCATION, "Cannot have arrival delay < 0 on reinforcement %s", ptr->name); delay = 0; } @@ -6041,14 +6057,16 @@ void parse_reinforcement(mission *pm) if (rforce_obj == NULL) { if ((instance = wing_name_lookup(ptr->name, 1)) == -1) { - Warning(LOCATION, "Reinforcement %s not found as ship or wing", ptr->name); + if (!Qtfred_running) + Warning(LOCATION, "Reinforcement %s not found as ship or wing", ptr->name); return; } } else { // Individual ships in wings can't be reinforcements - FUBAR if (rforce_obj->wingnum >= 0) { - Warning(LOCATION, "Reinforcement %s is part of a wing - Ignoring reinforcement declaration", ptr->name); + if (!Qtfred_running) + Warning(LOCATION, "Reinforcement %s is part of a wing - Ignoring reinforcement declaration", ptr->name); return; } else @@ -6798,7 +6816,9 @@ bool parse_mission(mission *pm, int flags) if (!post_process_mission(pm)) return false; - if ((saved_warning_count - Global_warning_count) > 10 || (saved_error_count - Global_error_count) > 0) { + // QtFRED surfaces parse issues through its own error checker, so skip this summary popup there; Fred2 and the game still show it. + if (!Qtfred_running && + ((saved_warning_count - Global_warning_count) > 10 || (saved_error_count - Global_error_count) > 0)) { char text[512]; sprintf(text, "Warning!\n\nThe current mission has generated %d warnings and/or errors during load. These are usually caused by corrupted ship models or syntax errors in the mission file. While FreeSpace Open will attempt to compensate for these issues, it cannot guarantee a trouble-free gameplay experience. Source Code Project staff cannot provide assistance or support for these problems, as they are caused by the mission's data files, not FreeSpace Open's source code.", (saved_warning_count - Global_warning_count) + (saved_error_count - Global_error_count)); popup(PF_TITLE_BIG | PF_TITLE_RED | PF_USE_AFFIRMATIVE_ICON | PF_NO_NETWORKING, 1, POPUP_OK, text); @@ -6936,7 +6956,9 @@ bool post_process_mission(mission *pm) error_msg += "\n\n(Bad node appears to be: "; error_msg += bad_node_str; error_msg += ")\n"; - Warning(LOCATION, "%s", error_msg.c_str()); + // QtFRED surfaces SEXP errors through ErrorChecker's fred_check_sexp; skip the popup there. + if (!Qtfred_running) + Warning(LOCATION, "%s", error_msg.c_str()); // syntax errors are recoverable in Fred but not FS if (!Fred_running && !sexp_recoverable_error(result)) { @@ -7732,7 +7754,8 @@ void mission_parse_set_up_initial_docks() // display an error if necessary if (dfi.maintained_variables.int_value == 0) { - Warning(LOCATION, "In the docking group containing %s, every ship has an arrival cue set to false. The group will not appear in-mission!\n", pobjp->name); + if (!Qtfred_running) + Warning(LOCATION, "In the docking group containing %s, every ship has an arrival cue set to false. The group will not appear in-mission!\n", pobjp->name); // for FRED, we must arbitrarily choose a dock leader, otherwise the entire docked group will not be loaded if (Fred_running) @@ -7740,7 +7763,8 @@ void mission_parse_set_up_initial_docks() } else if (dfi.maintained_variables.int_value > 1) { - Warning(LOCATION, "In the docking group containing %s, there is more than one ship with a non-false arrival cue! There can only be one such ship. Setting all arrival cues except %s to false...\n", dfi.maintained_variables.objp_value->name, dfi.maintained_variables.objp_value->name); + if (!Qtfred_running) + Warning(LOCATION, "In the docking group containing %s, there is more than one ship with a non-false arrival cue! There can only be one such ship. Setting all arrival cues except %s to false...\n", dfi.maintained_variables.objp_value->name, dfi.maintained_variables.objp_value->name); } // clear dfi stuff diff --git a/fred2/fred.cpp b/fred2/fred.cpp index 9b2d906928b..89ea2165875 100644 --- a/fred2/fred.cpp +++ b/fred2/fred.cpp @@ -106,6 +106,7 @@ pending_message Pending_messages[MAX_PENDING_MESSAGES]; CFREDApp theApp; int Fred_running = 1; +int Qtfred_running = 0; int FrameCount = 0; bool Fred_active = true; int Update_window = 1; diff --git a/freespace2/freespace.cpp b/freespace2/freespace.cpp index ca28087d648..d29b648f330 100644 --- a/freespace2/freespace.cpp +++ b/freespace2/freespace.cpp @@ -399,6 +399,7 @@ int Show_net_stats; bool Pre_player_entry = false; int Fred_running = 0; +int Qtfred_running = 0; bool running_unittests = false; // required for hudtarget... kinda dumb, but meh diff --git a/qtfred/help-src/doc/dialogs/ErrorCheckerDialog.html b/qtfred/help-src/doc/dialogs/ErrorCheckerDialog.html index 1e49bf8168f..ee63eb0671c 100644 --- a/qtfred/help-src/doc/dialogs/ErrorCheckerDialog.html +++ b/qtfred/help-src/doc/dialogs/ErrorCheckerDialog.html @@ -8,7 +8,82 @@

Error Checker

-

Accessible via Tools › Error Checker.

-
Full documentation for this tool is not yet written.
+

Opens via Tools › Error Checker. Also runs automatically +before saving when issues are present.

+

Scans the entire mission for structural problems, design errors, configuration +warnings, and potential issues. Results are listed by severity with a description +of each problem found.

+ +

Controls

+ + + + + + +
ControlDescription
RerunRuns the check again. Use this after making fixes to confirm + they are resolved.
Check Potential IssuesWhen checked, Potential Issue entries are + included in the results alongside errors and warnings. When unchecked, + potential issues are silently omitted.
Apply Auto-CorrectionsWhen checked, FRED automatically corrects + issues it can resolve without user input each time a check runs. Only applies + to correctable errors and warnings; Critical Errors are never auto-corrected. + Auto-corrections are applied before results are displayed.
Status barShows a summary of the most recent check: the total + number of entries found, or No check has been run yet before the + first run.
+ +

Severity levels

+ + + + + + +
SeverityMeaning
Critical ErrorA serious structural problem that FRED cannot + automatically correct. The mission may fail to load or behave unpredictably + in-game. Must be fixed manually before the mission is usable.
ErrorA mission design problem that must be fixed. The mission may + not play correctly.
WarningA configuration issue that may cause unexpected behavior + in-game.
Potential IssueA situation that may be intentional but is worth + reviewing. Only shown when Check Potential Issues is + checked.
+ +

What is checked

+

The checker inspects the following areas of the mission:

+
    +
  • Object list integrity and name uniqueness
  • +
  • Ships — SEXPs, AI goals, anchor references, docking configuration, and loadout + weapon availability
  • +
  • Wings — structure, SEXPs, AI goals, anchor references, wave thresholds, and + accepted orders consistency
  • +
  • Player starts and player wing membership
  • +
  • Waypoint path names for conflicts with object names
  • +
  • Reinforcement name references
  • +
  • Mission events and mission objectives — SEXP validation
  • +
  • Briefings — icon ID uniqueness
  • +
  • Debriefings — SEXP validation
  • +
  • Asteroid field target ship name validity
  • +
  • Initially-docked groups — must have exactly one non-false arrival cue
  • +
  • Team loadout — weapons used in starting wings must be present in the + team loadout pool
  • +
+ +

Pre-save mode

+

When saving a mission that has unresolved errors, the Error Checker opens +automatically in pre-save mode. In this mode three additional buttons appear at the +bottom of the dialog:

+ + + + + +
ButtonDescription
CancelAborts the save and returns to the editor so issues can be + addressed.
Save As IsSaves the mission despite the reported issues.
Fix and SaveApplies all available auto-corrections and then saves. + Only available when Apply Auto-Corrections is + checked.
+ +

Preferences

+

The Check Potential Issues and Apply Auto-Corrections +settings are also found in File › Preferences › Error +Checker. They share the same stored value — changing one updates the other. +Use Preferences to set the default behavior for all future check runs.

diff --git a/qtfred/source_groups.cmake b/qtfred/source_groups.cmake index 43b06165d9b..b16fd43fb91 100644 --- a/qtfred/source_groups.cmake +++ b/qtfred/source_groups.cmake @@ -56,6 +56,8 @@ add_file_folder("Source/Mission/Dialogs" src/mission/dialogs/DebriefingDialogModel.h src/mission/dialogs/FictionViewerDialogModel.cpp src/mission/dialogs/FictionViewerDialogModel.h + src/mission/dialogs/ErrorCheckerDialogModel.cpp + src/mission/dialogs/ErrorCheckerDialogModel.h src/mission/dialogs/FormWingDialogModel.cpp src/mission/dialogs/FormWingDialogModel.h src/mission/dialogs/GlobalShipFlagsDialogModel.cpp @@ -166,6 +168,8 @@ add_file_folder("Source/UI/Dialogs" src/ui/dialogs/CommandBriefingDialog.h src/ui/dialogs/DebriefingDialog.cpp src/ui/dialogs/DebriefingDialog.h + src/ui/dialogs/ErrorCheckerDialog.cpp + src/ui/dialogs/ErrorCheckerDialog.h src/ui/dialogs/FictionViewerDialog.cpp src/ui/dialogs/FictionViewerDialog.h src/ui/dialogs/FormWingDialog.cpp @@ -283,6 +287,8 @@ add_file_folder("Source/UI/Panels" add_file_folder("Source/UI/Util" src/ui/util/default_dir.cpp src/ui/util/default_dir.h + src/ui/util/ErrorChecker.cpp + src/ui/util/ErrorChecker.h src/ui/util/ImageRenderer.cpp src/ui/util/ImageRenderer.h src/ui/util/menu.cpp @@ -340,6 +346,7 @@ add_file_folder("UI" ui/CustomStringsDialog.ui ui/CustomWingNamesDialog.ui ui/DebriefingDialog.ui + ui/ErrorCheckerDialog.ui ui/FictionViewerDialog.ui ui/FormWingDialog.ui ui/FredView.ui diff --git a/qtfred/src/main.cpp b/qtfred/src/main.cpp index e52d1068377..a08dd820264 100644 --- a/qtfred/src/main.cpp +++ b/qtfred/src/main.cpp @@ -29,6 +29,7 @@ // Globals needed by the engine when built in 'FRED' mode. int Fred_running = 1; +int Qtfred_running = 1; int Show_cpu = 0; // Empty functions to make fred link with the sexp_mission_set_subspace diff --git a/qtfred/src/mission/Editor.cpp b/qtfred/src/mission/Editor.cpp index 2f148873679..4837ba88906 100644 --- a/qtfred/src/mission/Editor.cpp +++ b/qtfred/src/mission/Editor.cpp @@ -65,7 +65,7 @@ std::pair query_referenced_in_ai_goals(sexp_ref_type type, const return std::make_pair(-1, sexp_src::NONE); } -// Used in the FRED drop-down menu and in error_check_initial_orders +// Used in the FRED drop-down menu and in ErrorChecker::checkInitialOrders // NOTE: Certain goals (Form On Wing, Rearm, Chase Weapon, Fly To Ship) aren't listed here. This may or may not be intentional, // but if they are added in the future, it will be necessary to verify correct functionality in the various FRED dialog functions. ai_goal_list Ai_goal_list[] = { @@ -341,8 +341,8 @@ bool Editor::loadMission(const std::string& mission_name, int flags) { if (!Fred_migrated_immobile_ships.empty()) { SCP_string msg = "The \"immobile\" ship flag has been superseded by the \"don't-change-position\", and \"don't-change-orientation\" flags. " "All ships which previously had \"Does Not Move\" checked in the ship flags editor will now have both \"Does Not Change Position\" and " - "\"Does Not Change Orientation\" checked. After you close this dialog, the error checker will check for any potential issues, including " - "issues involving these flags.\n\nThe following ships have been migrated:"; + "\"Does Not Change Orientation\" checked.\n\nWould you like to open the error checker now to review any potential issues " + "involving these flags?\n\nThe following ships have been migrated:"; for (int shipnum : Fred_migrated_immobile_ships) { msg += "\n\t"; @@ -350,11 +350,14 @@ bool Editor::loadMission(const std::string& mission_name, int flags) { } truncate_message_lines(msg, 30); - _lastActiveViewport->dialogProvider->showButtonDialog(DialogType::Information, + auto z = _lastActiveViewport->dialogProvider->showButtonDialog(DialogType::Question, "Ship Flag Migration", msg, - { DialogButton::Ok }); - _lastActiveViewport->Error_checker_checks_potential_issues_once = true; + { DialogButton::Yes, DialogButton::No }); + if (z == DialogButton::Yes) { + // Consumed by FredView::autoRunErrorChecker after loadMission returns. + _lastActiveViewport->Error_checker_force_display_potentials_once = true; + } } obj_merge_created_list(); @@ -416,21 +419,7 @@ bool Editor::loadMission(const std::string& mission_name, int flags) { _weapon_usage[i][Team_data[i].weaponry_pool[j]] = 0; } } - // double check the used pool is empty - for (j = 0; j < static_cast(Weapon_info.size()); j++) { - if (_weapon_usage[i][j] != 0) { - Warning(LOCATION, - "%s is used in wings of team %d but was not in the loadout. Fixing now", - Weapon_info[j].name, - i + 1); - - // add the weapon as a new entry - Team_data[i].weaponry_pool[Team_data[i].num_weapon_choices] = j; - Team_data[i].weaponry_count[Team_data[i].num_weapon_choices] = _weapon_usage[i][j]; - strcpy_s(Team_data[i].weaponry_amount_variable[Team_data[i].num_weapon_choices], ""); - strcpy_s(Team_data[i].weaponry_pool_variable[Team_data[i].num_weapon_choices++], ""); - } - } + // Weapons used in wings but missing from the loadout pool are flagged by the error checker. } Assert(Mission_palette >= 0); @@ -2134,1324 +2123,28 @@ int Editor::get_prev_visible_subsys(ship * shipp, ship_subsys * *prev_subsys) { Int3(); // should be impossible to miss return 0; } -bool Editor::global_error_check() { - int z; - - z = global_error_check_impl(); - if (!z) { - _lastActiveViewport->dialogProvider->showButtonDialog(DialogType::Information, - "Woohoo!", - "No errors were detected in this mission", - { DialogButton::Ok }); - } - - for (z = 0; z < obj_count; z++) { - if (err_flags[z]) { - delete[] names[z]; - } - } - - obj_count = 0; - - return !z; +const ai_goal_list* Editor::getAi_goal_list() +{ + return Ai_goal_list; } -int Editor::global_error_check_impl() { - - char buf[256]; - const char* str; - int bs, i, j, n, s, t, z, ai, count, ship, wing, obj, team, point, multi; - object* ptr; - brief_stage* sp; - SCP_string anchor_message; - SCP_set anchors_checked; - - g_err = multi = 0; - if (The_mission.game_type & MISSION_TYPE_MULTI) { - multi = 1; - } - -// if (!stricmp(The_mission.name, "Untitled")) -// if (error("You haven't given this mission a title yet.\nThis is done from the Mission Specs Editor (Shift-N).")) -// return 1; - - // cycle though all the objects and verify every possible aspect of them - obj_count = t = 0; - ptr = GET_FIRST(&obj_used_list); - while (ptr != END_OF_LIST(&obj_used_list)) { - names[obj_count] = NULL; - err_flags[obj_count] = 0; - i = ptr->instance; - if ((ptr->type == OBJ_SHIP) || (ptr->type == OBJ_START)) { - if (i < 0 || i >= MAX_SHIPS) { - return internal_error("An object has an illegal ship index"); - } - - z = Ships[i].ship_info_index; - if ((z < 0) || (z >= static_cast(Ship_info.size()))) { - return internal_error("A ship has an illegal class"); - } - - if (ptr->type == OBJ_START) { - t++; - if (!(Ship_info[z].flags[Ship::Info_Flags::Player_ship])) { - ptr->type = OBJ_SHIP; - Player_starts--; - t--; - if (error("Invalid ship type for a player. Ship has been reset to non-player ship.")) { - return 1; - } - } - - for (n = count = 0; n < MAX_SHIP_PRIMARY_BANKS; n++) { - if (Ships[i].weapons.primary_bank_weapons[n] >= 0) { - count++; - } - } - - if (!count) { - if (error("Player \"%s\" has no primary weapons. Should have at least 1", Ships[i].ship_name)) { - return 1; - } - } - - for (n = count = 0; n < MAX_SHIP_SECONDARY_BANKS; n++) { - if (Ships[i].weapons.secondary_bank_weapons[n] >= 0) { - count++; - } - } - } - - if (Ships[i].objnum != OBJ_INDEX(ptr)) { - return internal_error("Object/ship references are corrupt"); - } - - names[obj_count] = Ships[i].ship_name; - wing = Ships[i].wingnum; - if (wing >= 0) { // ship is part of a wing, so check this - if (wing < 0 || wing >= MAX_WINGS) { // completely out of range? - return internal_error("A ship has an illegal wing index"); - } - - j = Wings[wing].wave_count; - if (!j) { - return internal_error("A ship is in a non-existent wing"); - } - - if (j < 0 || j > MAX_SHIPS_PER_WING) { - return internal_error("Invalid number of ships in wing \"%s\"", Wings[z].name); - } - - while (j--) { - if (wing_objects[wing][j] == OBJ_INDEX(ptr)) { // look for object in wing's table - break; - } - } - - if (j < 0) { - return internal_error("Ship/wing references are corrupt"); - } - - // wing squad logo check - Goober5000 - if (strlen(Wings[wing].wing_squad_filename) > 0) //-V805 - { - if (The_mission.game_type & MISSION_TYPE_MULTI) { - if (error("Wing squad logos are not displayed in multiplayer games.")) { - return 1; - } - } else { - if (ptr->type == OBJ_START) { - if (error( - "A squad logo was assigned to the player's wing. The player's squad logo will be displayed instead of the wing squad logo on ships in this wing.")) { - return 1; - } - } - } - } - } - - if ((Ships[i].flags[Ship::Ship_Flags::Kill_before_mission]) && (Ships[i].hotkey >= 0)) { - if (error("Ship flagged as \"destroy before mission start\" has a hotkey assignment")) { - return 1; - } - } - - if ((Ships[i].flags[Ship::Ship_Flags::Kill_before_mission]) && (ptr->type == OBJ_START)) { - if (error("Player start flagged as \"destroy before mission start\"")) { - return 1; - } - } - } else if (ptr->type == OBJ_WAYPOINT) { - int waypoint_num; - waypoint_list* wp_list = find_waypoint_list_with_instance(i, &waypoint_num); - - if (wp_list == NULL) { - return internal_error("Object references an illegal waypoint path number"); - } - - if (waypoint_num < 0 || (uint) waypoint_num >= wp_list->get_waypoints().size()) { - return internal_error("Object references an illegal waypoint number in path"); - } - - waypoint_stuff_name(buf, i); - names[obj_count] = new char[strlen(buf) + 1]; - strcpy(names[obj_count], buf); - err_flags[obj_count] = 1; - } else if (ptr->type == OBJ_POINT) { - //Shouldn't be needed anymore. - //If we really do need it, call me and I'll write a is_valid function for jumpnodes -WMC - } else if (ptr->type == OBJ_JUMP_NODE) { - //nothing needs to be done here, we just need to make sure the else doesn't occur - } else { - return internal_error("An unknown object type (%d) was detected", ptr->type); - } - - for (i = 0; i < obj_count; i++) { - if (names[i] && names[obj_count]) { - if (!stricmp(names[i], names[obj_count])) { - return internal_error("Duplicate object names (%s)", names[i]); - } - } - } - - obj_count++; - ptr = GET_NEXT(ptr); - } - - if (t != Player_starts) { - return internal_error("Total number of player ships is incorrect"); - } - - if (obj_count != Num_objects) { - return internal_error("Num_objects is incorrect"); - } - - count = 0; - for (i = 0; i < MAX_SHIPS; i++) { - if (Ships[i].objnum >= 0) { // is ship being used? - count++; - if (!query_valid_object(Ships[i].objnum)) { - return internal_error("Ship uses an unused object"); - } - - z = Objects[Ships[i].objnum].type; - if ((z != OBJ_SHIP) && (z != OBJ_START)) { - return internal_error("Object should be a ship, but isn't"); - } - - if (fred_check_sexp(Ships[i].arrival_cue, OPR_BOOL, "arrival cue of ship \"%s\"", Ships[i].ship_name)) { - return -1; - } - - if (fred_check_sexp(Ships[i].departure_cue, OPR_BOOL, "departure cue of ship \"%s\"", Ships[i].ship_name)) { - return -1; - } - - if (Ships[i].arrival_location != ArrivalLocation::AT_LOCATION) { - if (!Ships[i].arrival_anchor.isValid()) { - if (error("Ship \"%s\" requires a valid arrival target", Ships[i].ship_name)) { - return 1; - } - } - if (Ships[i].arrival_location == ArrivalLocation::FROM_DOCK_BAY) { - check_anchor_for_hangar_bay(anchor_message, anchors_checked, Ships[i].arrival_anchor, Ships[i].ship_name, true, true); - if (!anchor_message.empty() && error("%s", anchor_message.c_str())) { - return 1; - } - } - } - - if (Ships[i].departure_location != DepartureLocation::AT_LOCATION) { - if (!Ships[i].departure_anchor.isValid()) { - if (error("Ship \"%s\" requires a valid departure target", Ships[i].ship_name)) { - return 1; - } - } - if (Ships[i].departure_location == DepartureLocation::TO_DOCK_BAY) { - check_anchor_for_hangar_bay(anchor_message, anchors_checked, Ships[i].departure_anchor, Ships[i].ship_name, true, false); - if (!anchor_message.empty() && error("%s", anchor_message.c_str())) { - return 1; - } - } - } - - ai = Ships[i].ai_index; - if (ai < 0 || ai >= MAX_AI_INFO) { - return internal_error("AI index out of range for ship \"%s\"", Ships[i].ship_name); - } - - if (Ai_info[ai].shipnum != i) { - return internal_error("AI/ship references are corrupt"); - } - - if ((str = error_check_initial_orders(Ai_info[ai].goals, i, -1)) != nullptr) { - if (*str == '*') { - return internal_error("Initial orders error for ship \"%s\"\n\n%s", Ships[i].ship_name, str + 1); - } else if (*str == '!') { - return 1; - } else if (error("Initial orders error for ship \"%s\"\n\n%s", Ships[i].ship_name, str)) { - return 1; - } - } - - - for (dock_instance* dock_ptr = Objects[Ships[i].objnum].dock_list; dock_ptr != NULL; - dock_ptr = dock_ptr->next) { - obj = OBJ_INDEX(dock_ptr->docked_objp); - - if (!query_valid_object(obj)) { - return internal_error("Ship \"%s\" initially docked with non-existant ship", Ships[i].ship_name); - } - - if (Objects[obj].type != OBJ_SHIP && Objects[obj].type != OBJ_START) { - return internal_error("Ship \"%s\" initially docked with non-ship object", Ships[i].ship_name); - } - - ship = get_ship_from_obj(obj); - if (!ship_docking_valid(i, ship) && !ship_docking_valid(ship, i)) { - return internal_error("Docking illegal between \"%s\" and \"%s\" (initially docked)", - Ships[i].ship_name, - Ships[ship].ship_name); - } - - auto dock_list = get_docking_list(Ship_info[Ships[i].ship_info_index].model_num); - point = dock_ptr->dockpoint_used; - if (point < 0 || point >= (int)dock_list.size()) { - internal_error("Invalid docker point (\"%s\" initially docked with \"%s\")", - Ships[i].ship_name, - Ships[ship].ship_name); - } - - dock_list = get_docking_list(Ship_info[Ships[ship].ship_info_index].model_num); - point = dock_find_dockpoint_used_by_object(dock_ptr->docked_objp, &Objects[Ships[i].objnum]); - if (point < 0 || point >= (int)dock_list.size()) { - internal_error("Invalid dockee point (\"%s\" initially docked with \"%s\")", - Ships[i].ship_name, - Ships[ship].ship_name); - } - } - - wing = Ships[i].wingnum; - bool is_in_loadout_screen = (ptr->type == OBJ_START); - if (!is_in_loadout_screen && wing >= 0) { - if (multi && The_mission.game_type & MISSION_TYPE_MULTI_TEAMS) { - for (n = 0; n < MAX_TVT_WINGS; n++) { - if (!strcmp(Wings[wing].name, TVT_wing_names[n])) { - is_in_loadout_screen = true; - break; - } - } - } else { - for (n = 0; n < MAX_STARTING_WINGS; n++) { - if (!strcmp(Wings[wing].name, Starting_wing_names[n])) { - is_in_loadout_screen = true; - break; - } - } - } - } - if (is_in_loadout_screen) { - int illegal = 0; - z = Ships[i].ship_info_index; - for (n = 0; n < MAX_SHIP_PRIMARY_BANKS; n++) { - if (Ships[i].weapons.primary_bank_weapons[n] >= 0 - && !Ship_info[z].allowed_weapons[Ships[i].weapons.primary_bank_weapons[n]]) { - illegal++; - } - } - - for (n = 0; n < MAX_SHIP_SECONDARY_BANKS; n++) { - if (Ships[i].weapons.secondary_bank_weapons[n] >= 0 - && !Ship_info[z].allowed_weapons[Ships[i].weapons.secondary_bank_weapons[n]]) { - illegal++; - } - } - - if (illegal && error("%d illegal weapon(s) found on ship \"%s\"", illegal, Ships[i].ship_name)) { - return 1; - } - } - } - } - - if (count != ship_get_num_ships()) { - return internal_error("num_ships is incorrect"); - } - - count = 0; - for (i = 0; i < MAX_WINGS; i++) { - team = -1; - j = Wings[i].wave_count; - if (j) { // is wing being used? - count++; - if (j < 0 || j > MAX_SHIPS_PER_WING) { - return internal_error("Invalid number of ships in wing \"%s\"", Wings[i].name); - } - - while (j--) { - obj = wing_objects[i][j]; - if (obj < 0 || obj >= MAX_OBJECTS) { - return internal_error("Wing_objects has an illegal object index"); - } - - if (!query_valid_object(obj)) { - return internal_error("Wing_objects references an unused object"); - } - -// Now, at this point, we can assume several things. We have a valid object because -// we passed query_valid_object(), and all valid objects were already checked above, -// so this object has valid information, such as the instance. - - if ((Objects[obj].type == OBJ_SHIP) || (Objects[obj].type == OBJ_START)) { - ship = Objects[obj].instance; - wing_bash_ship_name(buf, Wings[i].name, j + 1); - if (stricmp(buf, Ships[ship].ship_name) != 0) { - return internal_error("Ship \"%s\" in wing should be called \"%s\"", - Ships[ship].ship_name, - buf); - } - - int ship_type = ship_query_general_type(ship); - if (ship_type < 0 || !(Ship_types[ship_type].flags[Ship::Type_Info_Flags::AI_can_form_wing])) { - if (error("Ship \"%s\" is an illegal type to be in a wing", Ships[ship].ship_name)) { - return 1; - } - } - } else { - return internal_error("Wing_objects of \"%s\" references an illegal object type", Wings[i].name); - } - - if (Ships[ship].wingnum != i) { - return internal_error("Wing/ship references are corrupt"); - } - - if (ship != Wings[i].ship_index[j]) { - return internal_error("Ship/wing references are corrupt"); - } - - if (team < 0) { - team = Ships[ship].team; - } else if (team != Ships[ship].team && team < 999) { - if (error("ship teams mixed within same wing (\"%s\")", Wings[i].name)) { - return 1; - } - } - } - - if ((Wings[i].special_ship < 0) || (Wings[i].special_ship >= Wings[i].wave_count)) { - return internal_error("Special ship out of range for \"%s\"", Wings[i].name); - } - - if (Wings[i].num_waves < 0) { - return internal_error("Number of waves for \"%s\" is negative", Wings[i].name); - } - - if (Wings[i].threshold < 0) { - return internal_error("Threshold for \"%s\" is invalid", Wings[i].name); - } - - if (Wings[i].threshold + Wings[i].wave_count > MAX_SHIPS_PER_WING) { - Wings[i].threshold = MAX_SHIPS_PER_WING - Wings[i].wave_count; - if (error("Threshold for wing \"%s\" is higher than allowed. Reset to %d", - Wings[i].name, - Wings[i].threshold)) { - return 1; - } - } - - for (j = 0; j < obj_count; j++) { - if (names[j]) { - if (!stricmp(names[j], Wings[i].name)) { - return internal_error("Wing name is also used by an object (%s)", names[j]); - } - } - } - - if (fred_check_sexp(Wings[i].arrival_cue, OPR_BOOL, "arrival cue of wing \"%s\"", Wings[i].name)) { - return -1; - } - - if (fred_check_sexp(Wings[i].departure_cue, OPR_BOOL, "departure cue of wing \"%s\"", Wings[i].name)) { - return -1; - } - - if (Wings[i].arrival_location != ArrivalLocation::AT_LOCATION) { - if (!Wings[i].arrival_anchor.isValid()) { - if (error("Wing \"%s\" requires a valid arrival target", Wings[i].name)) { - return 1; - } - } - if (Wings[i].arrival_location == ArrivalLocation::FROM_DOCK_BAY) { - check_anchor_for_hangar_bay(anchor_message, anchors_checked, Wings[i].arrival_anchor, Wings[i].name, false, true); - if (!anchor_message.empty() && error("%s", anchor_message.c_str())) { - return 1; - } - } - } - - if (Wings[i].departure_location != DepartureLocation::AT_LOCATION) { - if (!Wings[i].departure_anchor.isValid()) { - if (error("Wing \"%s\" requires a valid departure target", Wings[i].name)) { - return 1; - } - } - if (Wings[i].departure_location == DepartureLocation::TO_DOCK_BAY) { - check_anchor_for_hangar_bay(anchor_message, anchors_checked, Wings[i].departure_anchor, Wings[i].name, false, false); - if (!anchor_message.empty() && error("%s", anchor_message.c_str())) { - return 1; - } - } - } - - if ((str = error_check_initial_orders(Wings[i].ai_goals, -1, i)) != nullptr) { - if (*str == '*') { - return internal_error("Initial orders error for wing \"%s\"\n\n%s", Wings[i].name, str + 1); - } else if (*str == '!') { - return 1; - } else if (error("Initial orders error for wing \"%s\"\n\n%s", Wings[i].name, str)) { - return 1; - } - } - - } - } - - if (count != Num_wings) { - return internal_error("Num_wings is incorrect"); - } - - for (const auto &ii: Waypoint_lists) { - for (z = 0; z < obj_count; z++) { - if (names[z]) { - if (!stricmp(names[z], ii.get_name())) { - return internal_error("Waypoint path name is also used by an object (%s)", names[z]); - } - } - } - - for (const auto &jj: ii.get_waypoints()) { - waypoint_stuff_name(buf, jj); - for (z = 0; z < obj_count; z++) { - if (names[z]) { - if (!stricmp(names[z], buf)) { - break; - } - } - } - - if (z == obj_count) { - return internal_error("Waypoint \"%s\" not linked to an object", buf); - } - } - } - - if (Player_starts > MAX_PLAYERS) { - return internal_error("Number of player starts exceeds max limit"); - } - - if (!multi && (Player_starts > 1)) { - if (error("Multiple player starts exist, but this is a single player mission")) { - return 1; - } - } - - if (Num_reinforcements > MAX_REINFORCEMENTS) { - return internal_error("Number of reinforcements exceeds max limit"); - } - - for (i = 0; i < Num_reinforcements; i++) { - z = 0; - for (ship = 0; ship < MAX_SHIPS; ship++) { - if ((Ships[ship].objnum >= 0) && !stricmp(Ships[ship].ship_name, Reinforcements[i].name)) { - z = 1; - break; - } - } - - for (wing = 0; wing < MAX_WINGS; wing++) { - if (Wings[wing].wave_count && !stricmp(Wings[wing].name, Reinforcements[i].name)) { - z = 1; - break; - } - } - - if (!z) { - return internal_error("Reinforcement name not found in ships or wings"); - } - } - -/* for (i=0; i= MAX_SHIPS) // hacked! -1 should be illegal.. - return internal_error("Message originator index is out of range"); - - if (Ships[z].objnum == -1) - return internal_error("Message originator points to nonexistant ship"); +int Editor::getAigoal_list_size() { + // sizeof works here because Ai_goal_list is defined as an array in this same TU (above); + // keep this function defined alongside that array so it sees the array type, not a pointer. + return sizeof(Ai_goal_list) / sizeof(ai_goal_list); +} +SCP_vector Editor::get_docking_list(int model_index) { + int i; + polymodel *pm; + SCP_vector out; - if (fred_check_sexp(Messages[i].sexp, OPR_BOOL, - "Message formula from \"%s\"", Ships[Messages[i].who_from].ship_name)) - return -1; - }*/ + pm = model_get(model_index); + out.reserve(pm->n_docks); - Assert( - (Player_start_shipnum >= 0) && (Player_start_shipnum < MAX_SHIPS) && (Ships[Player_start_shipnum].objnum >= 0)); - i = global_error_check_player_wings(multi); - if (i) { - return i; - } - - for (i = 0; i < (int)Mission_events.size(); i++) { - if (fred_check_sexp(Mission_events[i].formula, OPR_NULL, "mission event \"%s\"", Mission_events[i].name.c_str())) { - return -1; - } - } - - for (i = 0; i < (int)Mission_goals.size(); i++) { - if (fred_check_sexp(Mission_goals[i].formula, OPR_BOOL, "mission goal \"%s\"", Mission_goals[i].name.c_str())) { - return -1; - } - } - - for (bs = 0; bs < Num_teams; bs++) { - for (s = 0; s < Briefings[bs].num_stages; s++) { - sp = &Briefings[bs].stages[s]; - t = sp->num_icons; - for (i = 0; i < t - 1; i++) { - for (j = i + 1; j < t; j++) { - if ((sp->icons[i].id > 0) && (sp->icons[i].id == sp->icons[j].id)) { - if (error("Duplicate icon IDs %d in briefing stage %d", sp->icons[i].id, s + 1)) { - return 1; - } - } - } - } - } - } - - for (j = 0; j < Num_teams; j++) { - for (i = 0; i < Debriefings[j].num_stages; i++) { - if (fred_check_sexp(Debriefings[j].stages[i].formula, OPR_BOOL, "debriefing stage %d", i + 1)) { - return -1; - } - } - } - - // for all wings, be sure that the orders accepted for all ships are the same for all ships - // in the wing - for (i = 0; i < MAX_WINGS; i++) { - int starting_wing; - - if (!Wings[i].wave_count) { - continue; - } - - // determine if this wing is a starting wing of the player - starting_wing = (ship_starting_wing_lookup(Wings[i].name) != -1); - - // first, be sure this isn't a reinforcement wing. - if (starting_wing && (Wings[i].flags[Ship::Wing_Flags::Reinforcement])) { - if (error( - "Starting Wing %s marked as reinforcement. This wing\nshould either be renamed, or unmarked as reinforcement.", - Wings[i].name)) { - return 1; - } - } - - std::set default_orders; - int default_orders_idx = -1; - for (j = 0; j < Wings[i].wave_count; j++) { - // exclude players from the check - if (Objects[Ships[Wings[i].ship_index[j]].objnum].type == OBJ_START) { - continue; - } - - const std::set& orders = Ships[Wings[i].ship_index[j]].orders_accepted; - - if (default_orders_idx < 0) { - default_orders_idx = j; - default_orders = orders; - - } else if (default_orders != orders) { - if (error( - "%s and %s will accept different orders. All ships in a wing must accept the same Player Orders.", - Ships[Wings[i].ship_index[j]].ship_name, - Ships[Wings[i].ship_index[default_orders_idx]].ship_name)) { - return 1; - } - } - } - -/* Goober5000 - this is not necessary - // make sure that these ignored orders are the same for all starting wings of the player - if ( starting_wing ) { - if ( starting_orders == -1 ) { - starting_orders = default_orders; - } else { - if ( starting_orders != default_orders ) { - if ( error("Player starting wing %s has orders which don't match other starting wings\n", Wings[i].name) ){ - return 1; - } - } - } - } -*/ - } - - //This should never ever be a problem -WMC - /* - if (Num_jump_nodes < 0){ - return internal_error("Jump node count is illegal"); - }*/ - - // FIXME: This call was in the original function but the code of that function was entirely commented out - //fred_check_message_personas(); - - return g_err; -} -int Editor::error(const char* msg, ...) { - char buf[2048]; - va_list args; - - va_start(args, msg); - vsnprintf(buf, sizeof(buf) - 1, msg, args); - va_end(args); - buf[sizeof(buf) - 1] = '\0'; - - g_err = 1; - if (_lastActiveViewport->dialogProvider->showButtonDialog(DialogType::Error, - "Error", - buf, - { DialogButton::Ok, DialogButton::Cancel }) - == DialogButton::Ok) { - return 0; - } - - return 1; -} -int Editor::internal_error(const char* msg, ...) { - SCP_string buf; - va_list args; - - va_start(args, msg); - vsprintf(buf, msg, args); - va_end(args); - - g_err = 1; - -#ifndef NDEBUG - buf += "\n\nThis is an internal error. Please notify a coder about this. Click cancel to debug."; - - if (_lastActiveViewport->dialogProvider->showButtonDialog(DialogType::Error, - "Internal Error", - buf, - { DialogButton::Ok, DialogButton::Cancel }) - == DialogButton::Cancel) - Int3(); // drop to debugger so the problem can be analyzed. -#else - _lastActiveViewport->dialogProvider->showButtonDialog(DialogType::Error, "Error", buf, { DialogButton::Ok }); -#endif - - return -1; -} -int Editor::fred_check_sexp(int sexp, int type, const char* location, ...) { - SCP_string location_buf, sexp_buf, error_buf, bad_node_str, issue_msg; - int err = 0, z, faulty_node; - va_list args; - - va_start(args, location); - vsprintf(location_buf, location, args); - va_end(args); - - if (sexp == -1) - return 0; - - z = check_sexp_syntax(sexp, type, 1, &faulty_node); - if (z) - { - convert_sexp_to_string(sexp_buf, sexp, SEXP_ERROR_CHECK_MODE); - truncate_message_lines(sexp_buf, 30); - - stuff_sexp_text_string(bad_node_str, faulty_node, SEXP_ERROR_CHECK_MODE); - if (!bad_node_str.empty()) // the previous function adds a space at the end - bad_node_str.pop_back(); - - sprintf(error_buf, "Error in %s: %s\n\n%s\n\n(Bad node appears to be: %s)", location_buf.c_str(), sexp_error_message(z), sexp_buf.c_str(), bad_node_str.c_str()); - - if (z < 0 && z > -100) - err = 1; - - if (err) - return internal_error("%s", error_buf.c_str()); - - if (error("%s", error_buf.c_str())) - return 1; - } - - if (_lastActiveViewport->Error_checker_checks_potential_issues || _lastActiveViewport->Error_checker_checks_potential_issues_once) - z = check_sexp_potential_issues(sexp, &faulty_node, issue_msg); - if (z) - { - convert_sexp_to_string(sexp_buf, sexp, SEXP_ERROR_CHECK_MODE); - truncate_message_lines(sexp_buf, 30); - - stuff_sexp_text_string(bad_node_str, faulty_node, SEXP_ERROR_CHECK_MODE); - if (!bad_node_str.empty()) // the previous function adds a space at the end - bad_node_str.pop_back(); - - sprintf(error_buf, "Potential issue detected in %s:\n\n%s\n\n%s\n\n(Suspect node appears to be: %s)", location_buf.c_str(), issue_msg.c_str(), sexp_buf.c_str(), bad_node_str.c_str()); - - if (_lastActiveViewport->dialogProvider->showButtonDialog(DialogType::Warning, "Warning", error_buf.c_str(), { DialogButton::Ok, DialogButton::Cancel }) != DialogButton::Ok) - return 1; - } - _lastActiveViewport->Error_checker_checks_potential_issues_once = false; - - return 0; -} -const char* Editor::error_check_initial_orders(ai_goal* goals, int ship, int wing) { - char *source; - int i, j, flag, found, inst, team, team2; - object *ptr; - - if (ship >= 0) { - source = Ships[ship].ship_name; - team = Ships[ship].team; - for (i=0; i= 0); - Assert(Wings[wing].wave_count > 0); - source = Wings[wing].name; - team = Ships[Objects[wing_objects[wing][0]].instance].team; - for (j=0; j 0) { - if (*goals[i].target_name == '<') - return "Invalid target"; - - if (!stricmp(goals[i].target_name, source)) { - if (ship >= 0) - return "Target of ship's goal is itself"; - else - return "Target of wing's goal is itself"; - } - } - - inst = team2 = -1; - if (flag == 1) { // target waypoint required - if (find_matching_waypoint_list(goals[i].target_name) == NULL) - return "*Invalid target waypoint path name"; - - } else if (flag == 2) { // target ship required - ptr = GET_FIRST(&obj_used_list); - while (ptr != END_OF_LIST(&obj_used_list)) { - if (ptr->type == OBJ_SHIP || ptr->type == OBJ_START) { - inst = ptr->instance; - if (!stricmp(goals[i].target_name, Ships[inst].ship_name)) { - found = 1; - break; - } - } - - ptr = GET_NEXT(ptr); - } - - if (!found) - return "*Invalid target ship name"; - - if (wing >= 0) { // check if target ship is in wing - if (Ships[inst].wingnum == wing && Objects[Ships[inst].objnum].type != OBJ_START) - return "Target ship of wing's goal is within said wing"; - } - - team2 = Ships[inst].team; - - } else if (flag == 3) { // target wing required - for (j=0; j= MAX_WINGS) - return "*Invalid target wing name"; - - if (ship >= 0) { // check if ship is in target wing - if (Ships[ship].wingnum == j) - return "Target wing of ship's goal is same wing said ship is part of"; - } - - team2 = Ships[Objects[wing_objects[j][0]].instance].team; - - } else if (flag == 4) { - ptr = GET_FIRST(&obj_used_list); - while (ptr != END_OF_LIST(&obj_used_list)) { - if (ptr->type == OBJ_SHIP || ptr->type == OBJ_START) { - inst = ptr->instance; - if (!stricmp(goals[i].target_name, Ships[inst].ship_name)) { - found = 2; - break; - } - - } else if (ptr->type == OBJ_WAYPOINT) { - if (!stricmp(goals[i].target_name, object_name(OBJ_INDEX(ptr)))) { - found = 1; - break; - } - } - - ptr = GET_NEXT(ptr); - } - - if (!found) - return "*Invalid target ship or waypoint name"; - - if (found == 2) { - if (wing >= 0) { // check if target ship is in wing - if (Ships[inst].wingnum == wing && Objects[Ships[inst].objnum].type != OBJ_START) - return "Target ship of wing's goal is within said wing"; - } - - team2 = Ships[inst].team; - } - } - - switch (goals[i].ai_mode) { - case AI_GOAL_DESTROY_SUBSYSTEM: - Assert(flag == 2 && inst >= 0); - if (ship_find_subsys(&Ships[inst], goals[i].docker.name) < 0) - return "Unknown subsystem type"; - - break; - - case AI_GOAL_DOCK: { - int dock1 = -1, dock2 = -1, model1, model2; - - Assert(flag == 2 && inst >= 0); - if (!ship_docking_valid(ship, inst)) - return "Docking illegal between given ship types"; - - model1 = Ship_info[Ships[ship].ship_info_index].model_num; - auto model1Docks = get_docking_list(model1); - for (j = 0; j < (int)model1Docks.size(); ++j) { - if (!stricmp(goals[i].docker.name, model1Docks[j].c_str())) { - dock1 = j; - break; - } - } - - model2 = Ship_info[Ships[inst].ship_info_index].model_num; - auto model2Docks = get_docking_list(model2); - for (j = 0; j < (int)model2Docks.size(); ++j) { - if (!stricmp(goals[i].dockee.name, model2Docks[j].c_str())) { - dock2 = j; - break; - } - } - - if (dock1 < 0) - return "Invalid docker point"; - - if (dock2 < 0) - return "Invalid dockee point"; - - if ((dock1 >= 0) && (dock2 >= 0)) { - if ( !(model_get_dock_index_type(model1, dock1) & model_get_dock_index_type(model2, dock2)) ) - return "Dock points are incompatible"; - } - - break; - } - - default: - break; - } - - switch (goals[i].ai_mode) { - case AI_GOAL_GUARD: - case AI_GOAL_GUARD_WING: - if (team != team2) { // MK, added support for TEAM_NEUTRAL. Won't this work? - if (ship >= 0) - return "Ship assigned to guard a different team"; - else - return "Wing assigned to guard a different team"; - } - break; - - case AI_GOAL_CHASE: - case AI_GOAL_CHASE_WING: - case AI_GOAL_DESTROY_SUBSYSTEM: - case AI_GOAL_DISARM_SHIP: - case AI_GOAL_DISARM_SHIP_TACTICAL: - case AI_GOAL_DISABLE_SHIP: - case AI_GOAL_DISABLE_SHIP_TACTICAL: - if (team == team2) { - if (ship >= 0) - return "Ship assigned to attack same team"; - else - return "Wings assigned to attack same team"; - } - break; - - default: - break; - } - } - - return NULL; -} -const char* Editor::get_order_name(ai_goal_mode order) { - if (order == AI_GOAL_NONE) // special case - return "None"; - - for (auto& entry : Ai_goal_list) - if (entry.def == order) - return entry.name; - - return "???"; -} -const ai_goal_list* Editor::getAi_goal_list() -{ - return Ai_goal_list; -} -int Editor::getAigoal_list_size() { - return sizeof(Ai_goal_list) / sizeof(ai_goal_list); -} -SCP_vector Editor::get_docking_list(int model_index) { - int i; - polymodel *pm; - SCP_vector out; - - pm = model_get(model_index); - out.reserve(pm->n_docks); - - for (i=0; in_docks; i++) - out.push_back(pm->docking_bays[i].name); + for (i=0; in_docks; i++) + out.emplace_back(pm->docking_bays[i].name); return out; } -int Editor::global_error_check_player_wings(int multi) { - int i, z, err; - int starting_wing_count[MAX_STARTING_WINGS]; - int tvt_wing_count[MAX_TVT_WINGS]; - - object *ptr; - SCP_string starting_wing_list = ""; - SCP_string tvt_wing_list = ""; - - // check team wings in tvt - if ( multi && The_mission.game_type & MISSION_TYPE_MULTI_TEAMS ) - { - for (i=0; i 1 wave for multiplayer - if ( multi ) - { - if ( The_mission.game_type & MISSION_TYPE_MULTI_TEAMS ) - { - for (i=0; i= 0 && Wings[TVT_wings[i]].num_waves > 1) - { - Wings[TVT_wings[i]].num_waves = 1; - if (error("%s wing must contain only 1 wave.\nThis change has been made for you.", TVT_wing_names[i])) - return 1; - } - } - } - else - { - for (i=0; i= 0 && Wings[Starting_wings[i]].num_waves > 1) - { - Wings[Starting_wings[i]].num_waves = 1; - if (error("%s wing must contain only 1 wave.\nThis change has been made for you.", Starting_wing_names[i])) - return 1; - } - } - } - } - - // check number of ships in player wing - if ( multi && The_mission.game_type & MISSION_TYPE_MULTI_TEAMS ) - { - for (i=0; i= 0 && Wings[TVT_wings[i]].wave_count > 4) - { - if (error("%s wing has too many ships. Should only have 4 max.", TVT_wing_names[i])) - return 1; - } - } - } - else - { - for (i=0; i= 0 && Wings[Starting_wings[i]].wave_count > 4) - { - if (error("%s wing has too many ships. Should only have 4 max.", Starting_wing_names[i])) - return 1; - } - } - } - - // check arrival delay in tvt - if ( multi && The_mission.game_type & MISSION_TYPE_MULTI_TEAMS ) - { - for (i=0; i= 0 && Wings[TVT_wings[i]].arrival_delay > 0) - { - if (error("%s wing shouldn't have a non-zero arrival delay", TVT_wing_names[i])) - return 1; - } - } - } - - // check mixed-species in a wing for multi missions - if (multi) - { - if ( The_mission.game_type & MISSION_TYPE_MULTI_TEAMS ) - { - for (i=0; i= 0) - { - if (global_error_check_mixed_player_wing(TVT_wings[i])) - return 1; - } - } - } - else - { - for (i=0; i= 0) - { - if (global_error_check_mixed_player_wing(Starting_wings[i])) - return 1; - } - } - } - } - - for (i=0; i 2) - starting_wing_list += ","; - starting_wing_list += " "; - } - else - { - starting_wing_list += "or "; - starting_wing_list += Starting_wing_names[i]; - } - } - for (i=0; i 2) - tvt_wing_list += ","; - tvt_wing_list += " "; - } - else - { - tvt_wing_list += "or "; - tvt_wing_list += TVT_wing_names[i]; - } - } - - // check players in wings - ptr = GET_FIRST(&obj_used_list); - while (ptr != END_OF_LIST(&obj_used_list)) - { - int ship_instance = ptr->instance; - err = 0; - - // this ship is a player? - if (ptr->type == OBJ_START) - { - // check if this ship is in a wing - z = Ships[ship_instance].wingnum; - if (z < 0) - { - err = 1; - } - else - { - int in_starting_wing = 0; - int in_tvt_wing = 0; - - // check which wing the player is in - for (i=0; i& teams, const SCP_vector& types) const { Assertion(Shield_sys_teams.size() == teams.size(), "Mismatched shield data from global shield dialog!"); diff --git a/qtfred/src/mission/Editor.h b/qtfred/src/mission/Editor.h index ecb005f913c..08636d1d70c 100644 --- a/qtfred/src/mission/Editor.h +++ b/qtfred/src/mission/Editor.h @@ -3,7 +3,6 @@ #include "EditorViewport.h" #include "FredRenderer.h" -#include #include #include #include @@ -265,9 +264,7 @@ class Editor : public QObject { void select_next_object(); void select_previous_object(); - bool global_error_check(); - - SCP_vector get_docking_list(int model_index); + static SCP_vector get_docking_list(int model_index); bool compareShieldSysData(const SCP_vector& teams, const SCP_vector& types) const; void exportShieldSysData(SCP_vector& teams, SCP_vector& types) const; @@ -283,7 +280,8 @@ class Editor : public QObject { static const ai_goal_list* getAi_goal_list(); static int getAigoal_list_size(); - const char* error_check_initial_orders(ai_goal* goals, int ship, int wing); + + void generate_team_weaponry_usage_list(int team, int* arr); private: void clearMission(bool fast_reload = false); @@ -309,12 +307,6 @@ class Editor : public QObject { bool already_deleting_wing = false; - // used by error checker, but needed in more than just one function. - char* names[MAX_OBJECTS]; - char err_flags[MAX_OBJECTS]; - int obj_count = 0; - int g_err = 0; - // ship and weapon usage pools int _ship_usage[MAX_TVT_TEAMS][MAX_SHIP_CLASSES]; int _weapon_usage[MAX_TVT_TEAMS][MAX_WEAPON_TYPES]; @@ -365,8 +357,6 @@ class Editor : public QObject { void generate_wing_weaponry_usage_list(int* arr, int wing); - void generate_team_weaponry_usage_list(int team, int* arr); - void generate_ship_usage_list(int* arr, int wing); int get_visible_sub_system_count(ship* shipp); @@ -375,20 +365,6 @@ class Editor : public QObject { int get_prev_visible_subsys(ship* shipp, ship_subsys** prev_subsys); - int global_error_check_impl(); - - int error(SCP_FORMAT_STRING const char* msg, ...) SCP_FORMAT_STRING_ARGS(2, 3); - int internal_error(SCP_FORMAT_STRING const char* msg, ...) SCP_FORMAT_STRING_ARGS(2, 3); - - int fred_check_sexp(int sexp, int type, const char* location, ...); - - - int global_error_check_mixed_player_wing(int w); - - int global_error_check_player_wings(int multi); - - static const char* get_order_name(ai_goal_mode order); - void updateStartingWingLoadoutUseCounts(); }; diff --git a/qtfred/src/mission/EditorViewport.cpp b/qtfred/src/mission/EditorViewport.cpp index 05969c9f5f0..ad13e1d91e9 100644 --- a/qtfred/src/mission/EditorViewport.cpp +++ b/qtfred/src/mission/EditorViewport.cpp @@ -140,6 +140,7 @@ void EditorViewport::loadSettings() { Move_ships_when_undocking = settings.value("move_ships_when_undocking", Move_ships_when_undocking).toBool(); Always_save_display_names = settings.value("always_save_display_names", Always_save_display_names).toBool(); Error_checker_checks_potential_issues = settings.value("error_checker_checks_potential_issues", Error_checker_checks_potential_issues).toBool(); + Error_checker_apply_auto_corrections = settings.value("error_checker_apply_auto_corrections", Error_checker_apply_auto_corrections).toBool(); Show_sexp_help_mission_events = settings.value("show_sexp_help_mission_events", Show_sexp_help_mission_events).toBool(); Show_sexp_help_mission_goals = settings.value("show_sexp_help_mission_goals", Show_sexp_help_mission_goals).toBool(); Show_sexp_help_mission_cutscenes = settings.value("show_sexp_help_mission_cutscenes", Show_sexp_help_mission_cutscenes).toBool(); @@ -180,6 +181,7 @@ void EditorViewport::saveSettings() const { settings.setValue("move_ships_when_undocking", Move_ships_when_undocking); settings.setValue("always_save_display_names", Always_save_display_names); settings.setValue("error_checker_checks_potential_issues", Error_checker_checks_potential_issues); + settings.setValue("error_checker_apply_auto_corrections", Error_checker_apply_auto_corrections); settings.setValue("show_sexp_help_mission_events", Show_sexp_help_mission_events); settings.setValue("show_sexp_help_mission_goals", Show_sexp_help_mission_goals); settings.setValue("show_sexp_help_mission_cutscenes", Show_sexp_help_mission_cutscenes); diff --git a/qtfred/src/mission/EditorViewport.h b/qtfred/src/mission/EditorViewport.h index 4dceef9754f..89edd105e64 100644 --- a/qtfred/src/mission/EditorViewport.h +++ b/qtfred/src/mission/EditorViewport.h @@ -208,7 +208,11 @@ class EditorViewport { bool Move_ships_when_undocking = true; bool Always_save_display_names = false; bool Error_checker_checks_potential_issues = true; - bool Error_checker_checks_potential_issues_once = false; + bool Error_checker_apply_auto_corrections = true; + // One-shot override: when set, the next auto-run of the error checker shows + // the dialog and forces potential issues on regardless of the user's saved + // preference. Consumed (cleared) by autoRunErrorChecker. Not persisted. + bool Error_checker_force_display_potentials_once = false; bool Show_sexp_help_mission_events = true; bool Show_sexp_help_mission_goals = true; diff --git a/qtfred/src/mission/dialogs/ErrorCheckerDialogModel.cpp b/qtfred/src/mission/dialogs/ErrorCheckerDialogModel.cpp new file mode 100644 index 00000000000..5ff3f249b9f --- /dev/null +++ b/qtfred/src/mission/dialogs/ErrorCheckerDialogModel.cpp @@ -0,0 +1,60 @@ +#include "ErrorCheckerDialogModel.h" + +#include "mission/EditorViewport.h" + +namespace fso::fred::dialogs { + + +ErrorCheckerDialogModel::ErrorCheckerDialogModel(QObject* parent, EditorViewport* viewport) + : AbstractDialogModel(parent, viewport) + , _checker(std::make_unique(viewport)) +{ +} + +bool ErrorCheckerDialogModel::apply() { + return true; +} + +void ErrorCheckerDialogModel::reject() { +} + +bool ErrorCheckerDialogModel::runCheck() { + _checker = std::make_unique(_viewport); + bool errors = _checker->runFullCheck(); + _hasBeenRun = true; + modelChanged(); + return errors; +} + +void ErrorCheckerDialogModel::clearErrors() { + _hasBeenRun = false; + modelChanged(); +} + +bool ErrorCheckerDialogModel::hasBeenRun() const { + return _hasBeenRun; +} + +const SCP_vector& ErrorCheckerDialogModel::getErrors() const { + return _checker->getErrors(); +} + +bool ErrorCheckerDialogModel::getCheckPotentialIssues() const { + return _viewport->Error_checker_checks_potential_issues; +} + +void ErrorCheckerDialogModel::setCheckPotentialIssues(bool value) { + _viewport->Error_checker_checks_potential_issues = value; + _viewport->saveSettings(); +} + +bool ErrorCheckerDialogModel::getApplyAutoCorrections() const { + return _viewport->Error_checker_apply_auto_corrections; +} + +void ErrorCheckerDialogModel::setApplyAutoCorrections(bool value) { + _viewport->Error_checker_apply_auto_corrections = value; + _viewport->saveSettings(); +} + +} // namespace fso::fred::dialogs diff --git a/qtfred/src/mission/dialogs/ErrorCheckerDialogModel.h b/qtfred/src/mission/dialogs/ErrorCheckerDialogModel.h new file mode 100644 index 00000000000..8a2b6758575 --- /dev/null +++ b/qtfred/src/mission/dialogs/ErrorCheckerDialogModel.h @@ -0,0 +1,37 @@ +#pragma once + +#include "AbstractDialogModel.h" +#include "ui/util/ErrorChecker.h" + +#include + +namespace fso::fred::dialogs { + +class ErrorCheckerDialogModel : public AbstractDialogModel { + Q_OBJECT + +public: + ErrorCheckerDialogModel(QObject* parent, EditorViewport* viewport); + ~ErrorCheckerDialogModel() override = default; + + bool apply() override; + void reject() override; + + bool runCheck(); + void clearErrors(); + + bool hasBeenRun() const; + const SCP_vector& getErrors() const; + + bool getCheckPotentialIssues() const; + void setCheckPotentialIssues(bool value); + + bool getApplyAutoCorrections() const; + void setApplyAutoCorrections(bool value); + +private: + std::unique_ptr _checker; + bool _hasBeenRun = false; +}; + +} // namespace fso::fred::dialogs diff --git a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp index 3e4251339a9..49e581a9107 100644 --- a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp +++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp @@ -12,7 +12,8 @@ PreferencesDialogModel::PreferencesDialogModel(QObject* parent, EditorViewport* , _offerAutosaveRecovery(viewport->Offer_autosave_recovery) , _moveShipsWhenUndocking(viewport->Move_ships_when_undocking) , _alwaysSaveDisplayNames(viewport->Always_save_display_names) - , _errorCheckerChecksForPotentialIssues(viewport->Error_checker_checks_potential_issues) + , _checkPotentialIssues(viewport->Error_checker_checks_potential_issues) + , _applyAutoCorrections(viewport->Error_checker_apply_auto_corrections) , _showSexpHelpMissionEvents(viewport->Show_sexp_help_mission_events) , _showSexpHelpMissionGoals(viewport->Show_sexp_help_mission_goals) , _showSexpHelpMissionCutscenes(viewport->Show_sexp_help_mission_cutscenes) @@ -43,8 +44,9 @@ PreferencesDialogModel::PreferencesDialogModel(QObject* parent, EditorViewport* bool PreferencesDialogModel::apply() { _viewport->Offer_autosave_recovery = _offerAutosaveRecovery; _viewport->Move_ships_when_undocking = _moveShipsWhenUndocking; - _viewport->Always_save_display_names = _alwaysSaveDisplayNames; - _viewport->Error_checker_checks_potential_issues = _errorCheckerChecksForPotentialIssues; + _viewport->Always_save_display_names = _alwaysSaveDisplayNames; + _viewport->Error_checker_checks_potential_issues = _checkPotentialIssues; + _viewport->Error_checker_apply_auto_corrections = _applyAutoCorrections; _viewport->Show_sexp_help_mission_events = _showSexpHelpMissionEvents; _viewport->Show_sexp_help_mission_goals = _showSexpHelpMissionGoals; _viewport->Show_sexp_help_mission_cutscenes = _showSexpHelpMissionCutscenes; @@ -99,8 +101,11 @@ void PreferencesDialogModel::setMoveShipsWhenUndocking(bool value) { modify(_mov bool PreferencesDialogModel::getAlwaysSaveDisplayNames() const { return _alwaysSaveDisplayNames; } void PreferencesDialogModel::setAlwaysSaveDisplayNames(bool value) { modify(_alwaysSaveDisplayNames, value); } -bool PreferencesDialogModel::getErrorCheckerChecksForPotentialIssues() const { return _errorCheckerChecksForPotentialIssues; } -void PreferencesDialogModel::setErrorCheckerChecksForPotentialIssues(bool value) { modify(_errorCheckerChecksForPotentialIssues, value); } +bool PreferencesDialogModel::getCheckPotentialIssues() const { return _checkPotentialIssues; } +void PreferencesDialogModel::setCheckPotentialIssues(bool value) { modify(_checkPotentialIssues, value); } + +bool PreferencesDialogModel::getApplyAutoCorrections() const { return _applyAutoCorrections; } +void PreferencesDialogModel::setApplyAutoCorrections(bool value) { modify(_applyAutoCorrections, value); } bool PreferencesDialogModel::getShowSexpHelpMissionEvents() const { return _showSexpHelpMissionEvents; } void PreferencesDialogModel::setShowSexpHelpMissionEvents(bool value) { modify(_showSexpHelpMissionEvents, value); } diff --git a/qtfred/src/mission/dialogs/PreferencesDialogModel.h b/qtfred/src/mission/dialogs/PreferencesDialogModel.h index 2cad46f5ab4..cd522fcfd8f 100644 --- a/qtfred/src/mission/dialogs/PreferencesDialogModel.h +++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.h @@ -27,8 +27,12 @@ class PreferencesDialogModel : public AbstractDialogModel { bool getAlwaysSaveDisplayNames() const; void setAlwaysSaveDisplayNames(bool value); - bool getErrorCheckerChecksForPotentialIssues() const; - void setErrorCheckerChecksForPotentialIssues(bool value); + // Error Checker + bool getCheckPotentialIssues() const; + void setCheckPotentialIssues(bool value); + + bool getApplyAutoCorrections() const; + void setApplyAutoCorrections(bool value); bool getShowSexpHelpMissionEvents() const; void setShowSexpHelpMissionEvents(bool value); @@ -69,7 +73,8 @@ class PreferencesDialogModel : public AbstractDialogModel { bool _offerAutosaveRecovery; bool _moveShipsWhenUndocking; bool _alwaysSaveDisplayNames; - bool _errorCheckerChecksForPotentialIssues; + bool _checkPotentialIssues; + bool _applyAutoCorrections; bool _showSexpHelpMissionEvents; bool _showSexpHelpMissionGoals; bool _showSexpHelpMissionCutscenes; diff --git a/qtfred/src/mission/dialogs/ShipEditor/ShipGoalsDialogModel.cpp b/qtfred/src/mission/dialogs/ShipEditor/ShipGoalsDialogModel.cpp index d9c7b5f1759..608f2c4b5f9 100644 --- a/qtfred/src/mission/dialogs/ShipEditor/ShipGoalsDialogModel.cpp +++ b/qtfred/src/mission/dialogs/ShipEditor/ShipGoalsDialogModel.cpp @@ -1,4 +1,5 @@ #include "ShipGoalsDialogModel.h" +#include "ui/util/ErrorChecker.h" #include #include #include @@ -97,7 +98,7 @@ namespace fso { if (((ptr->type == OBJ_SHIP) || (ptr->type == OBJ_START)) && (ptr->flags[Object::Object_Flags::Marked])) { self_ship = ptr->instance; goalp = Ai_info[Ships[self_ship].ai_index].goals; - verify_orders(self_ship); + verify_orders(); } ptr = GET_NEXT(ptr); @@ -108,29 +109,24 @@ namespace fso { return true; } - int ShipGoalsDialogModel::verify_orders(const int ship) + int ShipGoalsDialogModel::verify_orders() { - const char* str; - SCP_string error_message; - if ((str = _editor->error_check_initial_orders(goalp, self_ship, self_wing)) != nullptr) { - if (*str == '!') - return 1; - else if (*str == '*') - str++; - - if (ship >= 0) - sprintf(error_message, "Initial orders error for ship \"%s\"\n\n%s", Ships[ship].ship_name, str); - else - error_message = str; - auto button = _viewport->dialogProvider->showButtonDialog(DialogType::Error, - "Order Error", - error_message, - { DialogButton::Ok, DialogButton::Cancel }); - if (button != DialogButton::Ok) - return 1; + ErrorChecker checker(_viewport); + if (!checker.runCheck(ErrorCheckType::InitialOrders, {goalp, self_ship, self_wing})) + return 0; + + SCP_string message; + for (const auto& entry : checker.getErrors()) { + if (!message.empty()) + message += "\n\n"; + message += entry.message; } - return 0; + auto button = _viewport->dialogProvider->showButtonDialog(DialogType::Error, + "Order Error", + message, + { DialogButton::Ok, DialogButton::Cancel }); + return (button == DialogButton::Ok) ? 0 : 1; } void ShipGoalsDialogModel::update_item(const int item) diff --git a/qtfred/src/mission/dialogs/ShipEditor/ShipGoalsDialogModel.h b/qtfred/src/mission/dialogs/ShipEditor/ShipGoalsDialogModel.h index fd4ae77a06d..53fad8633c8 100644 --- a/qtfred/src/mission/dialogs/ShipEditor/ShipGoalsDialogModel.h +++ b/qtfred/src/mission/dialogs/ShipEditor/ShipGoalsDialogModel.h @@ -42,7 +42,7 @@ class ShipGoalsDialogModel : public AbstractDialogModel { bool m_multi_edit; ai_goal* goalp; - int verify_orders(const int ship = -1); + int verify_orders(); void update_item(const int item); diff --git a/qtfred/src/ui/FredView.cpp b/qtfred/src/ui/FredView.cpp index c186c79737d..114ec54e27b 100644 --- a/qtfred/src/ui/FredView.cpp +++ b/qtfred/src/ui/FredView.cpp @@ -58,6 +58,8 @@ #include #include #include +#include +#include #include #include @@ -305,6 +307,7 @@ void FredView::loadMissionFile(const QString& pathName, int flags) { fred->loadMission(pathToLoad, flags); QApplication::restoreOverrideCursor(); + autoRunErrorChecker(); } catch (const fso::fred::mission_load_error&) { QApplication::restoreOverrideCursor(); @@ -338,7 +341,80 @@ void FredView::on_actionSave_As_triggered(bool) { saveMissionAs(); } +bool FredView::performPreSaveCheck(int* outFixCount) { + // Potentials are advisory and intentionally ignored at save time; fix counts + // report only actual problems (Error/Warning/InternalError). + auto countNonPotential = [](const SCP_vector& entries) { + int n = 0; + for (const auto& e : entries) + if (e.severity != ErrorSeverity::Potential) + ++n; + return n; + }; + + // Clamp flags for the pre-save scan: no mutations, no potential issues. + const bool savedPotential = _viewport->Error_checker_checks_potential_issues; + const bool savedCorrections = _viewport->Error_checker_apply_auto_corrections; + _viewport->Error_checker_checks_potential_issues = false; + _viewport->Error_checker_apply_auto_corrections = false; + + // Use the normal error checker dialog in PreSave mode so the error cards are + // rendered identically to what the designer sees when running it manually. + dialogs::ErrorCheckerDialog dlg(this, _viewport, dialogs::ErrorCheckerDialog::Mode::PreSave); + const bool errors = dlg.runCheck(); // returns true when errors were found + + // Restore flags before any further work (including the optional fix run below). + _viewport->Error_checker_checks_potential_issues = savedPotential; + _viewport->Error_checker_apply_auto_corrections = savedCorrections; + + if (!errors) + return true; + + dlg.exec(); // modal — blocks until the designer clicks a button + + switch (dlg.preSaveAction()) { + case dialogs::ErrorCheckerDialog::PreSaveAction::Cancel: + return false; + + case dialogs::ErrorCheckerDialog::PreSaveAction::FixAndSave: { + const int beforeCount = countNonPotential(dlg.getErrors()); + + // Apply auto-corrections to in-memory data before the file is written. + _viewport->Error_checker_checks_potential_issues = false; + _viewport->Error_checker_apply_auto_corrections = true; + { + ErrorChecker fixer(_viewport); + fixer.runFullCheck(); + } + _viewport->Error_checker_apply_auto_corrections = savedCorrections; + + // Run a second clean check (no mutations) to see how many issues remain, + // so we can report to the designer exactly how many were resolved. + if (outFixCount) { + _viewport->Error_checker_checks_potential_issues = false; + ErrorChecker verifier(_viewport); + verifier.runFullCheck(); + *outFixCount = beforeCount - countNonPotential(verifier.getErrors()); + } + + _viewport->Error_checker_checks_potential_issues = savedPotential; + return true; + } + + case dialogs::ErrorCheckerDialog::PreSaveAction::SaveAsIs: + default: + return true; + } +} + bool FredView::saveMissionToCurrentPath() { + if (saveName.isEmpty()) + return saveMissionAs(); + + int fixCount = -1; + if (!performPreSaveCheck(&fixCount)) + return false; + Fred_mission_save save; save.set_save_format(_missionSaveFormat); save.set_always_save_display_names(_viewport->Always_save_display_names); @@ -347,15 +423,37 @@ bool FredView::saveMissionToCurrentPath() { save.set_fred_alt_names(Fred_alt_names); save.set_fred_callsigns(Fred_callsigns); - if (saveName.isEmpty()) { - return saveMissionAs(); - } - save.save_mission_file(saveName.replace('/', DIR_SEPARATOR_CHAR).toUtf8().constData()); _missionModified = false; + + if (fixCount > 0) + QMessageBox::information(this, tr("Auto-corrections Applied"), + tr("%1 issue(s) were automatically corrected before saving.").arg(fixCount)); + else if (fixCount == 0) + QMessageBox::information(this, tr("No Auto-corrections Applied"), + tr("No issues could be automatically corrected. The mission was saved with existing errors.")); + + // Keep the persistent error checker in sync if it is already open. + if (_errorCheckerDialog && _errorCheckerDialog->isVisible()) + _errorCheckerDialog->runCheck(); + return true; } + bool FredView::saveMissionAs() { + // Run the pre-save check before the file dialog so that cancelling does not + // leave the designer with a half-chosen save path. + int fixCount = -1; + if (!performPreSaveCheck(&fixCount)) + return false; + + const QString lastDir = fso::fred::util::getLastDir("missions/saveMission", CF_TYPE_MISSIONS); + saveName = QFileDialog::getSaveFileName(this, tr("Save mission"), lastDir, tr("FS2 missions (*.fs2)")); + if (saveName.isEmpty()) + return false; + + fso::fred::util::saveLastDir("missions/saveMission", saveName); + Fred_mission_save save; save.set_save_format(_missionSaveFormat); save.set_always_save_display_names(_viewport->Always_save_display_names); @@ -364,19 +462,20 @@ bool FredView::saveMissionAs() { save.set_fred_alt_names(Fred_alt_names); save.set_fred_callsigns(Fred_callsigns); - { - const QString lastDir = fso::fred::util::getLastDir("missions/saveMission", CF_TYPE_MISSIONS); - saveName = QFileDialog::getSaveFileName(this, tr("Save mission"), lastDir, tr("FS2 missions (*.fs2)")); + save.save_mission_file(saveName.replace('/', DIR_SEPARATOR_CHAR).toUtf8().constData()); + _missionModified = false; - if (saveName.isEmpty()) { - return false; - } + if (fixCount > 0) + QMessageBox::information(this, tr("Auto-corrections Applied"), + tr("%1 issue(s) were automatically corrected before saving.").arg(fixCount)); + else if (fixCount == 0) + QMessageBox::information(this, tr("No Auto-corrections Applied"), + tr("No issues could be automatically corrected. The mission was saved with existing errors.")); - fso::fred::util::saveLastDir("missions/saveMission", saveName); - } + // Keep the persistent error checker in sync if it is already open. + if (_errorCheckerDialog && _errorCheckerDialog->isVisible()) + _errorCheckerDialog->runCheck(); - save.save_mission_file(saveName.replace('/',DIR_SEPARATOR_CHAR).toUtf8().constData()); - _missionModified = false; return true; } @@ -632,6 +731,10 @@ void FredView::on_mission_loaded(const std::string& filepath) { // Clear browsed head ANIs so the new mission's message scan starts fresh. fso::fred::dialogs::MissionEventsDialogModel::clearBrowsedHeadAnis(); + if (_errorCheckerDialog) { + _errorCheckerDialog->clearErrors(); + } + if (_viewport != nullptr) { _viewport->reloadLayersFromMission(); _tbLayerComboDirty = true; @@ -2774,7 +2877,78 @@ void FredView::on_actionMark_Wing_triggered(bool) { } } void FredView::on_actionError_Checker_triggered(bool) { - fred->global_error_check(); + openAndRunErrorChecker(); +} + +void FredView::openAndRunErrorChecker() { + if (!_errorCheckerDialog) { + _errorCheckerDialog = new dialogs::ErrorCheckerDialog(this, _viewport); + _errorCheckerDialog->setAttribute(Qt::WA_DeleteOnClose); + connect(_errorCheckerDialog, &QObject::destroyed, this, [this]() { + _errorCheckerDialog = nullptr; + }); + } + _errorCheckerDialog->show(); + _errorCheckerDialog->raise(); + _errorCheckerDialog->activateWindow(); + _errorCheckerDialog->runCheck(); +} + +void FredView::autoRunErrorChecker() { + if (!_errorCheckerDialog) { + _errorCheckerDialog = new dialogs::ErrorCheckerDialog(this, _viewport); + _errorCheckerDialog->setAttribute(Qt::WA_DeleteOnClose); + connect(_errorCheckerDialog, &QObject::destroyed, this, [this]() { + _errorCheckerDialog = nullptr; + }); + } + + // Consume the one-shot "force review" flag (set e.g. after a data migration + // when the designer asked to review now). When set, we force the dialog open + // and override the display filter to include potentials for this session. + const bool forceReview = _viewport->Error_checker_force_display_potentials_once; + _viewport->Error_checker_force_display_potentials_once = false; + _errorCheckerDialog->setForcePotentialsDisplay(forceReview); + + // Never silently mutate mission data on an automatic (load-triggered) check. + // The designer can apply auto-corrections explicitly via the error checker dialog. + const bool savedCorrections = _viewport->Error_checker_apply_auto_corrections; + _viewport->Error_checker_apply_auto_corrections = false; + bool errors = _errorCheckerDialog->runCheck(); + _viewport->Error_checker_apply_auto_corrections = savedCorrections; + + if (forceReview) { + _errorCheckerDialog->show(); + _errorCheckerDialog->raise(); + _errorCheckerDialog->activateWindow(); + return; + } + + if (_errorCheckerDialog->isVisible()) { + if (errors) { + _errorCheckerDialog->raise(); + _errorCheckerDialog->activateWindow(); + } + return; + } + + if (!errors) { + return; + } + + QMessageBox msgBox(this); + msgBox.setIcon(QMessageBox::Warning); + msgBox.setWindowTitle(tr("Mission Errors Detected")); + msgBox.setText(tr("Errors were detected in the mission.")); + auto* showBtn = msgBox.addButton(tr("Show Errors"), QMessageBox::AcceptRole); + msgBox.addButton(tr("Dismiss"), QMessageBox::RejectRole); + msgBox.exec(); + + if (msgBox.clickedButton() == showBtn) { + _errorCheckerDialog->show(); + _errorCheckerDialog->raise(); + _errorCheckerDialog->activateWindow(); + } } void FredView::on_actionHelp_Topics_triggered(bool) { // Keep a single instance alive for the session. The help engine's contentWidget(), diff --git a/qtfred/src/ui/FredView.h b/qtfred/src/ui/FredView.h index ffc272c2c12..d6b3697c5da 100644 --- a/qtfred/src/ui/FredView.h +++ b/qtfred/src/ui/FredView.h @@ -29,6 +29,7 @@ class Editor; class RenderWidget; namespace dialogs { +class ErrorCheckerDialog; class ShipEditorDialog; class WingEditorDialog; class PropEditorDialog; @@ -200,6 +201,15 @@ class FredView: public QMainWindow, public IDialogProvider { private: bool saveMissionToCurrentPath(); bool saveMissionAs(); + void openAndRunErrorChecker(); + void autoRunErrorChecker(); + // Runs the error checker before a save (no mutations, no potential issues). + // If errors are found, shows the error checker in PreSave mode and applies + // auto-corrections if the designer chooses "Fix and Save". + // Returns false if the save should be cancelled. + // If outFixCount is provided it is set to the number of issues auto-corrected + // (0 if Fix and Save was chosen but nothing could be fixed, -1 if not attempted). + bool performPreSaveCheck(int* outFixCount = nullptr); void saveAsTemplate(); void loadTemplate(); bool maybePromptToSaveMissionChanges(const QString& actionDescription); @@ -271,6 +281,7 @@ class FredView: public QMainWindow, public IDialogProvider { Editor* fred = nullptr; EditorViewport* _viewport = nullptr; + fso::fred::dialogs::ErrorCheckerDialog* _errorCheckerDialog = nullptr; fso::fred::dialogs::ShipEditorDialog* _shipEditorDialog = nullptr; fso::fred::dialogs::WingEditorDialog* _wingEditorDialog = nullptr; fso::fred::dialogs::PropEditorDialog* _propEditorDialog = nullptr; diff --git a/qtfred/src/ui/dialogs/ErrorCheckerDialog.cpp b/qtfred/src/ui/dialogs/ErrorCheckerDialog.cpp new file mode 100644 index 00000000000..d0fb1c21764 --- /dev/null +++ b/qtfred/src/ui/dialogs/ErrorCheckerDialog.cpp @@ -0,0 +1,277 @@ +#include "ErrorCheckerDialog.h" +#include "ui_ErrorCheckerDialog.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace fso::fred::dialogs { + +ErrorCheckerDialog::ErrorCheckerDialog(QWidget* parent, EditorViewport* viewport, Mode mode) + : QDialog(parent) + , ui(new Ui::ErrorCheckerDialog()) + , _model(new ErrorCheckerDialogModel(this, viewport)) + , _mode(mode) +{ + ui->setupUi(this); + connect(_model.get(), &ErrorCheckerDialogModel::modelChanged, this, &ErrorCheckerDialog::updateUi); + + initializeUi(); + updateUi(); +} + +ErrorCheckerDialog::~ErrorCheckerDialog() = default; + +bool ErrorCheckerDialog::runCheck() { + return _model->runCheck(); +} + +void ErrorCheckerDialog::clearErrors() { + _model->clearErrors(); +} + +int ErrorCheckerDialog::getErrorCount() const { + return static_cast(_model->getErrors().size()); +} + +const SCP_vector& ErrorCheckerDialog::getErrors() const { + return _model->getErrors(); +} + +void ErrorCheckerDialog::setForcePotentialsDisplay(bool force) { + if (_forcePotentialsDisplay == force) + return; + _forcePotentialsDisplay = force; + updateUi(); +} + +void ErrorCheckerDialog::on_runButton_clicked() { + _model->runCheck(); +} + +void ErrorCheckerDialog::on_closeButton_clicked() { + close(); +} + +void ErrorCheckerDialog::on_checkPotentialIssues_toggled(bool checked) { + _model->setCheckPotentialIssues(checked); +} + +void ErrorCheckerDialog::on_checkApplyAutoCorrections_toggled(bool checked) { + _model->setApplyAutoCorrections(checked); +} + +void ErrorCheckerDialog::changeEvent(QEvent* event) { + QDialog::changeEvent(event); + if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange) { + updateUi(); + } +} + +void ErrorCheckerDialog::initializeUi() { + // --- Color legend bar (common to both modes) --- + // Sits between the toolbar row and the scroll area so designers always know what + // the stripe colors mean without having to hover over anything. + { + auto* legend = new QWidget(this); + auto* legendLayout = new QHBoxLayout(legend); + legendLayout->setContentsMargins(4, 2, 4, 2); + legendLayout->setSpacing(10); + + for (const auto& info : fso::fred::severity_info) { + auto* swatch = new QWidget(legend); + swatch->setFixedSize(12, 12); + swatch->setAutoFillBackground(true); + QPalette p = swatch->palette(); + p.setColor(QPalette::Window, QColor(info.r, info.g, info.b)); + swatch->setPalette(p); + swatch->setToolTip(tr(info.tooltip)); + legendLayout->addWidget(swatch); + + auto* lbl = new QLabel(tr(info.label), legend); + lbl->setToolTip(tr(info.tooltip)); + legendLayout->addWidget(lbl); + } + legendLayout->addStretch(); + + // Insert at index 1: after the toolbar layout, before the scroll area. + ui->mainLayout->insertWidget(1, legend); + } + + // --- Scroll area content (common to both modes) --- + auto* scrollContent = new QWidget(ui->errorScrollArea); + _errorLayout = new QVBoxLayout(scrollContent); + _errorLayout->setAlignment(Qt::AlignTop); + _errorLayout->setSpacing(4); + _errorLayout->setContentsMargins(4, 4, 4, 4); + ui->errorScrollArea->setWidget(scrollContent); + ui->errorScrollArea->setWidgetResizable(true); + + if (_mode == Mode::Normal) { + ui->checkPotentialIssues->setChecked(_model->getCheckPotentialIssues()); + ui->checkApplyAutoCorrections->setChecked(_model->getApplyAutoCorrections()); + return; + } + + // --- PreSave mode --- + setWindowTitle(tr("Pre-save Error Check")); + setWindowModality(Qt::WindowModal); + + // Authoring controls are not relevant for a one-shot pre-save scan + ui->runButton->hide(); + ui->checkPotentialIssues->hide(); + ui->checkApplyAutoCorrections->hide(); + + // Note explaining Fix and Save's scope, inserted above the button row + auto* scopeNote = new QLabel( + tr("\"Fix and Save\" only applies automatic corrections for simple, well-defined issues " + "(such as missing loadout pool entries). Complex errors must be addressed manually."), + this); + scopeNote->setWordWrap(true); + // Insert just before the last item (bottomLayout) in the main layout + ui->mainLayout->insertWidget(ui->mainLayout->count() - 1, scopeNote); + + // Replace the Close button with the three pre-save decision buttons + ui->closeButton->hide(); + + _fixSaveButton = new QPushButton(tr("Fix and Save"), this); + auto* saveAnywayButton = new QPushButton(tr("Save Anyway"), this); + auto* cancelButton = new QPushButton(tr("Cancel"), this); + + _fixSaveButton->setToolTip( + tr("Apply automatic corrections to simple, well-defined errors, then save.\n" + "Issues that cannot be auto-corrected will remain and must be fixed manually.")); + + // Insert before the hidden Close button so visual order matches expected flow + const int closeIdx = ui->bottomLayout->indexOf(ui->closeButton); + ui->bottomLayout->insertWidget(closeIdx, _fixSaveButton); + ui->bottomLayout->insertWidget(closeIdx + 1, saveAnywayButton); + ui->bottomLayout->insertWidget(closeIdx + 2, cancelButton); + + connect(_fixSaveButton, &QPushButton::clicked, this, [this]() { _preSaveAction = PreSaveAction::FixAndSave; accept(); }); + connect(saveAnywayButton, &QPushButton::clicked, this, [this]() { _preSaveAction = PreSaveAction::SaveAsIs; accept(); }); + connect(cancelButton, &QPushButton::clicked, this, [this]() { _preSaveAction = PreSaveAction::Cancel; reject(); }); +} + +void ErrorCheckerDialog::updateUi() { + while (QLayoutItem* item = _errorLayout->takeAt(0)) { + if (QWidget* w = item->widget()) + w->deleteLater(); + delete item; + } + + if (!_model->hasBeenRun()) { + ui->statusLabel->setText(tr("No check has been run yet.")); + if (_fixSaveButton) + _fixSaveButton->setEnabled(false); + return; + } + + // Build the display list: always-run checks collect everything; the preference + // only controls whether potential issues are visible in the UI. A transient + // override (setForcePotentialsDisplay) can force potentials on for a single + // session without changing the saved preference. + const bool showPotential = _forcePotentialsDisplay || _model->getCheckPotentialIssues(); + auto errors = _model->getErrors(); + if (!showPotential) { + errors.erase(std::remove_if(errors.begin(), errors.end(), + [](const ErrorEntry& e) { return e.severity == ErrorSeverity::Potential; }), + errors.end()); + } + + if (errors.empty()) { + ui->statusLabel->setText(tr("No errors found!")); + if (_fixSaveButton) + _fixSaveButton->setEnabled(false); + return; + } + + // Sort so the UI always shows Critical → Error → Warning → Potential, + // regardless of the order checks were run. The enum is ordered by severity so a + // plain less-than comparison gives the right result. + std::sort(errors.begin(), errors.end(), [](const ErrorEntry& a, const ErrorEntry& b) { + return a.severity < b.severity; + }); + + const QColor windowColor = ui->errorScrollArea->palette().color(QPalette::Window); + const QColor cardBg = windowColor.lightness() < 128 + ? windowColor.lighter(115) + : windowColor.darker(108); + + int errorCount = 0; + int warningCount = 0; + int potentialCount = 0; + bool hasAutoFixable = false; + + for (const auto& entry : errors) { + const fso::fred::SeverityInfo& info = fso::fred::infoFor(entry.severity); + + switch (entry.severity) { + case ErrorSeverity::Error: + ++errorCount; + hasAutoFixable = true; + break; + case ErrorSeverity::InternalError: + ++errorCount; + break; + case ErrorSeverity::Warning: + ++warningCount; + hasAutoFixable = true; + break; + case ErrorSeverity::Potential: + ++potentialCount; + break; + } + + auto* card = new QFrame(); + card->setFrameShape(QFrame::StyledPanel); + card->setFrameShadow(QFrame::Plain); + card->setAutoFillBackground(true); + QPalette cardPalette = card->palette(); + cardPalette.setColor(QPalette::Window, cardBg); + card->setPalette(cardPalette); + + auto* cardLayout = new QHBoxLayout(card); + cardLayout->setContentsMargins(0, 0, 0, 0); + cardLayout->setSpacing(0); + + auto* stripe = new QWidget(card); + stripe->setFixedWidth(5); + stripe->setAutoFillBackground(true); + QPalette stripePalette = stripe->palette(); + stripePalette.setColor(QPalette::Window, QColor(info.r, info.g, info.b)); + stripe->setPalette(stripePalette); + // Tooltip on the stripe gives context without cluttering the message text + stripe->setToolTip(tr("%1 — %2").arg(tr(info.label), tr(info.tooltip))); + cardLayout->addWidget(stripe); + + auto* label = new QLabel(QString::fromStdString(entry.message), card); + label->setWordWrap(true); + label->setContentsMargins(8, 6, 8, 6); + cardLayout->addWidget(label); + + _errorLayout->addWidget(card); + } + + QStringList parts; + if (errorCount > 0) + parts << tr("%1 error(s)").arg(errorCount); + if (warningCount > 0) + parts << tr("%1 warning(s)").arg(warningCount); + if (potentialCount > 0) + parts << tr("%1 potential issue(s)").arg(potentialCount); + ui->statusLabel->setText(parts.join(tr(", ")) + tr(" found.")); + + // "Fix and Save" is only enabled when there are entries the auto-corrector can address. + // InternalErrors are data-integrity failures that the corrector cannot resolve. + if (_fixSaveButton) + _fixSaveButton->setEnabled(hasAutoFixable); +} + +} // namespace fso::fred::dialogs diff --git a/qtfred/src/ui/dialogs/ErrorCheckerDialog.h b/qtfred/src/ui/dialogs/ErrorCheckerDialog.h new file mode 100644 index 00000000000..95967918a43 --- /dev/null +++ b/qtfred/src/ui/dialogs/ErrorCheckerDialog.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include + +#include "mission/dialogs/ErrorCheckerDialogModel.h" + +class QVBoxLayout; + +namespace fso::fred::dialogs { + +namespace Ui { +class ErrorCheckerDialog; +} + +class ErrorCheckerDialog final : public QDialog { + Q_OBJECT + +public: + enum class Mode { Normal, PreSave }; + + // Action chosen by the designer in PreSave mode. + // Only meaningful after exec() returns in PreSave mode. + enum class PreSaveAction { Cancel, SaveAsIs, FixAndSave }; + + explicit ErrorCheckerDialog(QWidget* parent, EditorViewport* viewport, Mode mode = Mode::Normal); + ~ErrorCheckerDialog() override; + + PreSaveAction preSaveAction() const { return _preSaveAction; } + + // Number of entries in the most recent check result (0 if not yet run). + int getErrorCount() const; + const SCP_vector& getErrors() const; + + // Force potential issues to be displayed on the next updateUi pass even if + // the user's saved preference has them hidden. Does not persist and does + // not affect checkbox state. Intended for contexts that want to draw + // attention to potentials without overwriting user preferences (e.g. after + // a data migration). Caller can clear by passing false. + void setForcePotentialsDisplay(bool force); + +public slots: // NOLINT(readability-redundant-access-specifiers) + bool runCheck(); // returns true if errors were found + void clearErrors(); + +private slots: + void on_runButton_clicked(); + void on_closeButton_clicked(); + void on_checkPotentialIssues_toggled(bool checked); + void on_checkApplyAutoCorrections_toggled(bool checked); + +protected: + void changeEvent(QEvent* event) override; + +private: // NOLINT(readability-redundant-access-specifiers) + void initializeUi(); + void updateUi(); + + std::unique_ptr ui; + std::unique_ptr _model; + QVBoxLayout* _errorLayout = nullptr; + + Mode _mode = Mode::Normal; + PreSaveAction _preSaveAction = PreSaveAction::Cancel; + QPushButton* _fixSaveButton = nullptr; // PreSave mode only; used in updateUi + bool _forcePotentialsDisplay = false; +}; + +} // namespace fso::fred::dialogs diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.cpp b/qtfred/src/ui/dialogs/PreferencesDialog.cpp index dbc9b47a3a7..328d074d352 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.cpp +++ b/qtfred/src/ui/dialogs/PreferencesDialog.cpp @@ -87,7 +87,8 @@ void PreferencesDialog::updateUi() { ui->offerAutosaveRecovery->setChecked(_model->getOfferAutosaveRecovery()); ui->moveShipsWhenUndocking->setChecked(_model->getMoveShipsWhenUndocking()); ui->alwaysSaveDisplayNames->setChecked(_model->getAlwaysSaveDisplayNames()); - ui->errorCheckerChecksForPotentialIssues->setChecked(_model->getErrorCheckerChecksForPotentialIssues()); + ui->checkPotentialIssues->setChecked(_model->getCheckPotentialIssues()); + ui->applyAutoCorrections->setChecked(_model->getApplyAutoCorrections()); ui->themeCombo->setCurrentIndex(_model->getDarkMode() ? 1 : 0); const int iconSize = _model->getToolbarIconSize(); @@ -131,8 +132,12 @@ void PreferencesDialog::on_alwaysSaveDisplayNames_toggled(bool checked) { _model->setAlwaysSaveDisplayNames(checked); } -void PreferencesDialog::on_errorCheckerChecksForPotentialIssues_toggled(bool checked) { - _model->setErrorCheckerChecksForPotentialIssues(checked); +void PreferencesDialog::on_checkPotentialIssues_toggled(bool checked) { + _model->setCheckPotentialIssues(checked); +} + +void PreferencesDialog::on_applyAutoCorrections_toggled(bool checked) { + _model->setApplyAutoCorrections(checked); } void PreferencesDialog::on_toolbarIconSizeCombo_currentIndexChanged(int index) { diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.h b/qtfred/src/ui/dialogs/PreferencesDialog.h index 26adc412a26..b51032c3cb6 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.h +++ b/qtfred/src/ui/dialogs/PreferencesDialog.h @@ -26,7 +26,8 @@ private slots: void on_offerAutosaveRecovery_toggled(bool checked); void on_moveShipsWhenUndocking_toggled(bool checked); void on_alwaysSaveDisplayNames_toggled(bool checked); - void on_errorCheckerChecksForPotentialIssues_toggled(bool checked); + void on_checkPotentialIssues_toggled(bool checked); + void on_applyAutoCorrections_toggled(bool checked); void on_toolbarIconSizeCombo_currentIndexChanged(int index); void on_themeCombo_currentIndexChanged(int index); void on_showSexpHelpMissionEvents_toggled(bool checked); diff --git a/qtfred/src/ui/util/ErrorChecker.cpp b/qtfred/src/ui/util/ErrorChecker.cpp new file mode 100644 index 00000000000..c28de375e52 --- /dev/null +++ b/qtfred/src/ui/util/ErrorChecker.cpp @@ -0,0 +1,1540 @@ +#include "ui/util/ErrorChecker.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include "mission/Editor.h" +#include "mission/object.h" + +namespace fso::fred { + +ErrorChecker::ErrorChecker(EditorViewport* viewport) : _viewport(viewport) {} + +bool ErrorChecker::runFullCheck() { + _collected_errors.clear(); + _anchors_checked.clear(); + g_err = 0; + + // Frees any waypoint-name strings allocated by populateNames()/checkObjectList() + // on every exit path — including early returns from internal_error(). + struct NamesCleanupGuard { + ErrorChecker* self; + ~NamesCleanupGuard() { + for (int z = 0; z < self->obj_count; z++) { + if (self->err_flags[z]) + delete[] self->names[z]; + } + self->obj_count = 0; + } + } cleanup_guard{this}; + + if (checkObjectList() != 0) return true; + if (checkShips() != 0) return true; + if (checkWings() != 0) return true; + if (checkWaypointPaths() != 0) return true; + if (checkPlayerStarts() != 0) return true; + if (checkReinforcements() != 0) return true; + if (checkPlayerWings() != 0) return true; + if (checkTeamLoadout() != 0) return true; + if (checkMissionEvents() != 0) return true; + if (checkMissionGoals() != 0) return true; + if (checkBriefings() != 0) return true; + if (checkDebriefings() != 0) return true; + if (checkWingOrders() != 0) return true; + if (checkAsteroidTargets() != 0) return true; + if (checkDockingGroupCues() != 0) return true; + + return g_err != 0; +} + +bool ErrorChecker::runCheck(ErrorCheckType type, const ErrorCheckContext& ctx) { + g_err = 0; + _collected_errors.clear(); + _anchors_checked.clear(); + + // Same cleanup guard as runFullCheck — harmless for checks that don't populate names. + struct NamesCleanupGuard { + ErrorChecker* self; + ~NamesCleanupGuard() { + for (int z = 0; z < self->obj_count; z++) { + if (self->err_flags[z]) + delete[] self->names[z]; + } + self->obj_count = 0; + } + } cleanup_guard{this}; + + switch (type) { + case ErrorCheckType::InitialOrders: checkInitialOrders(ctx.goals, ctx.ship, ctx.wing); break; + case ErrorCheckType::ObjectList: checkObjectList(); break; + case ErrorCheckType::Ships: checkShips(); break; + case ErrorCheckType::Wings: checkWings(); break; // calls populateNames() internally + case ErrorCheckType::WaypointPaths: checkWaypointPaths(); break; // calls populateNames() internally + case ErrorCheckType::PlayerStarts: checkPlayerStarts(); break; + case ErrorCheckType::Reinforcements: checkReinforcements(); break; + case ErrorCheckType::PlayerWings: checkPlayerWings(); break; + case ErrorCheckType::MissionEvents: checkMissionEvents(); break; + case ErrorCheckType::MissionGoals: checkMissionGoals(); break; + case ErrorCheckType::Briefings: checkBriefings(); break; + case ErrorCheckType::Debriefings: checkDebriefings(); break; + case ErrorCheckType::WingOrders: checkWingOrders(); break; + case ErrorCheckType::AsteroidTargets: checkAsteroidTargets(); break; + case ErrorCheckType::DockingGroupCues: checkDockingGroupCues(); break; + case ErrorCheckType::TeamLoadout: checkTeamLoadout(); break; + } + + return g_err != 0; +} + +const SCP_vector& ErrorChecker::getErrors() const { + return _collected_errors; +} + +void ErrorChecker::error(const char* msg, ...) { + char buf[2048]; + va_list args; + + va_start(args, msg); + vsnprintf(buf, sizeof(buf) - 1, msg, args); + va_end(args); + buf[sizeof(buf) - 1] = '\0'; + + g_err = 1; + _collected_errors.push_back({buf, ErrorSeverity::Error}); +} + +int ErrorChecker::internal_error(const char* msg, ...) { + SCP_string buf; + va_list args; + + va_start(args, msg); + vsprintf(buf, msg, args); + va_end(args); + + g_err = 1; + _collected_errors.push_back({buf, ErrorSeverity::InternalError}); + return -1; +} + +void ErrorChecker::warning(const char* msg, ...) { + char buf[2048]; + va_list args; + + va_start(args, msg); + vsnprintf(buf, sizeof(buf) - 1, msg, args); + va_end(args); + buf[sizeof(buf) - 1] = '\0'; + + g_err = 1; + _collected_errors.push_back({buf, ErrorSeverity::Warning}); +} + +void ErrorChecker::potential(const char* msg, ...) { + char buf[2048]; + va_list args; + + va_start(args, msg); + vsnprintf(buf, sizeof(buf) - 1, msg, args); + va_end(args); + buf[sizeof(buf) - 1] = '\0'; + + _collected_errors.push_back({buf, ErrorSeverity::Potential}); +} + +int ErrorChecker::fred_check_sexp(int sexp, int type, const char* location, ...) { + SCP_string location_buf, sexp_buf, error_buf, bad_node_str, issue_msg; + int err = 0, faulty_node; + va_list args; + + va_start(args, location); + vsprintf(location_buf, location, args); + va_end(args); + + if (sexp == -1) + return 0; + + int z = check_sexp_syntax(sexp, type, 1, &faulty_node); + if (z) { + convert_sexp_to_string(sexp_buf, sexp, SEXP_ERROR_CHECK_MODE); + truncate_message_lines(sexp_buf, 30); + + stuff_sexp_text_string(bad_node_str, faulty_node, SEXP_ERROR_CHECK_MODE); + if (!bad_node_str.empty()) + bad_node_str.pop_back(); + + sprintf(error_buf, + "Error in %s: %s\n\n%s\n\n(Bad node appears to be: %s)", + location_buf.c_str(), + sexp_error_message(z), + sexp_buf.c_str(), + bad_node_str.c_str()); + + if (z < 0 && z > -100) + err = 1; + + if (err) + return internal_error("%s", error_buf.c_str()); + + error("%s", error_buf.c_str()); + } + + { + int potential_z = check_sexp_potential_issues(sexp, &faulty_node, issue_msg); + if (potential_z) { + convert_sexp_to_string(sexp_buf, sexp, SEXP_ERROR_CHECK_MODE); + truncate_message_lines(sexp_buf, 30); + + stuff_sexp_text_string(bad_node_str, faulty_node, SEXP_ERROR_CHECK_MODE); + if (!bad_node_str.empty()) + bad_node_str.pop_back(); + + sprintf(error_buf, + "Potential issue detected in %s:\n\n%s\n\n%s\n\n(Suspect node appears to be: %s)", + location_buf.c_str(), + issue_msg.c_str(), + sexp_buf.c_str(), + bad_node_str.c_str()); + + _collected_errors.push_back({error_buf, ErrorSeverity::Potential}); + } + } + + return 0; +} + +void ErrorChecker::populateNames() { + // Free any previously allocated waypoint name strings before repopulating, + // so this function is safe to call multiple times. + for (int z = 0; z < obj_count; z++) { + if (err_flags[z]) + delete[] names[z]; + } + obj_count = 0; + object* ptr = GET_FIRST(&obj_used_list); + while (ptr != END_OF_LIST(&obj_used_list)) { + names[obj_count] = nullptr; + err_flags[obj_count] = 0; + int i = ptr->instance; + if (ptr->type == OBJ_SHIP || ptr->type == OBJ_START) { + if (i >= 0 && i < MAX_SHIPS) + names[obj_count] = Ships[i].ship_name; + } else if (ptr->type == OBJ_WAYPOINT) { + int waypoint_num; + waypoint_list* wp_list = find_waypoint_list_with_instance(i, &waypoint_num); + if (wp_list != nullptr && waypoint_num >= 0 && (uint)waypoint_num < wp_list->get_waypoints().size()) { + char buf[256]; + waypoint_stuff_name(buf, i); + names[obj_count] = new char[strlen(buf) + 1]; + strcpy(names[obj_count], buf); + err_flags[obj_count] = 1; + } + } + obj_count++; + ptr = GET_NEXT(ptr); + } +} + +int ErrorChecker::checkObjectList() { + + int t; + obj_count = t = 0; + object* ptr = GET_FIRST(&obj_used_list); + while (ptr != END_OF_LIST(&obj_used_list)) { + names[obj_count] = nullptr; + err_flags[obj_count] = 0; + int i = ptr->instance; + if ((ptr->type == OBJ_SHIP) || (ptr->type == OBJ_START)) { + if (i < 0 || i >= MAX_SHIPS) { + return internal_error("An object has an illegal ship index"); + } + + int z = Ships[i].ship_info_index; + if (!SCP_vector_inbounds(Ship_info, z)) { + return internal_error("A ship has an illegal class"); + } + + if (ptr->type == OBJ_START) { + t++; + if (!(Ship_info[z].flags[Ship::Info_Flags::Player_ship])) { + if (_viewport->Error_checker_apply_auto_corrections) { + ptr->type = OBJ_SHIP; + Player_starts--; + t--; + } + error("Invalid ship type for a player.%s", + _viewport->Error_checker_apply_auto_corrections + ? " Ship has been reset to non-player ship." + : ""); + } + + int count = 0; + for (int primary_bank_weapon : Ships[i].weapons.primary_bank_weapons) { + if (primary_bank_weapon >= 0) { + count++; + } + } + + if (!count) { + warning("Player \"%s\" has no primary weapons. Should have at least 1", Ships[i].ship_name); + } + } + + if (Ships[i].objnum != OBJ_INDEX(ptr)) { + return internal_error("Object/ship references are corrupt"); + } + + names[obj_count] = Ships[i].ship_name; + int w = Ships[i].wingnum; + if (w >= 0) { + if (w >= MAX_WINGS) { + return internal_error("A ship has an illegal wing index"); + } + + int j = Wings[w].wave_count; + if (!j) { + return internal_error("A ship is in a non-existent wing"); + } + + if (j < 0 || j > MAX_SHIPS_PER_WING) { + return internal_error("Invalid number of ships in wing \"%s\"", Wings[w].name); + } + + while (j--) { + if (_viewport->editor->wing_objects[w][j] == OBJ_INDEX(ptr)) { + break; + } + } + + if (j < 0) { + return internal_error("Ship/wing references are corrupt"); + } + + if (strlen(Wings[w].wing_squad_filename) > 0) //-V805 + { + if (The_mission.game_type & MISSION_TYPE_MULTI) { + potential("Wing squad logos are not displayed in multiplayer games."); + } else { + if (ptr->type == OBJ_START) { + potential("A squad logo was assigned to the player's wing. The player's squad logo will be displayed instead of the wing squad logo on ships in this wing."); + } + } + } + } + + if ((Ships[i].flags[Ship::Ship_Flags::Kill_before_mission]) && (Ships[i].hotkey >= 0)) { + potential("Ship flagged as \"destroy before mission start\" has a hotkey assignment"); + } + + if ((Ships[i].flags[Ship::Ship_Flags::Kill_before_mission]) && (ptr->type == OBJ_START)) { + error("Player start flagged as \"destroy before mission start\""); + } + + if ((Ships[i].flags[Ship::Ship_Flags::Kill_before_mission]) && (Ships[i].final_death_time < 0)) { + error("Ship \"%s\" is flagged as \"destroy before mission start\" but has a negative destroy time", + Ships[i].ship_name); + } + } else if (ptr->type == OBJ_WAYPOINT) { + int waypoint_num; + waypoint_list* wp_list = find_waypoint_list_with_instance(i, &waypoint_num); + + if (wp_list == nullptr) { + return internal_error("Object references an illegal waypoint path number"); + } + + if (waypoint_num < 0 || (uint)waypoint_num >= wp_list->get_waypoints().size()) { + return internal_error("Object references an illegal waypoint number in path"); + } + + char buf[256]; + waypoint_stuff_name(buf, i); + names[obj_count] = new char[strlen(buf) + 1]; + strcpy(names[obj_count], buf); + err_flags[obj_count] = 1; + } else if (ptr->type == OBJ_POINT) { + // Briefing icons are editor-only objects, not mission objects; nothing to validate here. + } else if (ptr->type == OBJ_JUMP_NODE || ptr->type == OBJ_PROP) { + // nothing needed + } else { + return internal_error("An unknown object type (%d) was detected", ptr->type); + } + + for (i = 0; i < obj_count; i++) { + if (names[i] && names[obj_count]) { + if (!stricmp(names[i], names[obj_count])) { + return internal_error("Duplicate object names (%s)", names[i]); + } + } + } + + obj_count++; + ptr = GET_NEXT(ptr); + } + + if (t != Player_starts) { + return internal_error("Total number of player ships is incorrect"); + } + + if (obj_count != Num_objects) { + return internal_error("Num_objects is incorrect"); + } + + return 0; +} + +int ErrorChecker::checkShips() { + int multi = (The_mission.game_type & MISSION_TYPE_MULTI) ? 1 : 0; + + int count = 0; + for (int i = 0; i < MAX_SHIPS; i++) { + if (Ships[i].objnum >= 0) { + count++; + if (!query_valid_object(Ships[i].objnum)) { + return internal_error("Ship uses an unused object"); + } + + int z = Objects[Ships[i].objnum].type; + if ((z != OBJ_SHIP) && (z != OBJ_START)) { + return internal_error("Object should be a ship, but isn't"); + } + + if (fred_check_sexp(Ships[i].arrival_cue, OPR_BOOL, "arrival cue of ship \"%s\"", Ships[i].ship_name)) { + return -1; + } + + if (fred_check_sexp(Ships[i].departure_cue, OPR_BOOL, "departure cue of ship \"%s\"", Ships[i].ship_name)) { + return -1; + } + + if (Ships[i].arrival_location != ArrivalLocation::AT_LOCATION) { + if (!Ships[i].arrival_anchor.isValid()) { + error("Ship \"%s\" requires a valid arrival target", Ships[i].ship_name); + } + + if (Ships[i].arrival_location == ArrivalLocation::FROM_DOCK_BAY) { + SCP_string anchor_message; + check_anchor_for_hangar_bay(anchor_message, _anchors_checked, Ships[i].arrival_anchor, Ships[i].ship_name, true, true); + if (!anchor_message.empty()) + error("%s", anchor_message.c_str()); + } + } + + if (Ships[i].departure_location != DepartureLocation::AT_LOCATION) { + if (!Ships[i].departure_anchor.isValid()) { + error("Ship \"%s\" requires a valid departure target", Ships[i].ship_name); + } + if (Ships[i].departure_location == DepartureLocation::TO_DOCK_BAY) { + SCP_string anchor_message; + check_anchor_for_hangar_bay(anchor_message, _anchors_checked, Ships[i].departure_anchor, Ships[i].ship_name, true, false); + if (!anchor_message.empty()) + error("%s", anchor_message.c_str()); + } + } + + if (Ships[i].arrival_delay < 0) { + error("Ship \"%s\" has a negative arrival delay", Ships[i].ship_name); + } + + if (Ships[i].departure_delay < 0) { + error("Ship \"%s\" has a negative departure delay", Ships[i].ship_name); + } + + if (Ships[i].arrival_location != ArrivalLocation::AT_LOCATION && Ships[i].arrival_distance <= 0) { + error("Arrival distance for ship \"%s\" must be greater than 0", Ships[i].ship_name); + } + + if (Ships[i].flags[Ship::Ship_Flags::Force_shields_on] && + Ship_info[Ships[i].ship_info_index].flags[Ship::Info_Flags::Intrinsic_no_shields]) { + potential("Ship \"%s\" has both \"force shields on\" and \"no shields\" set, which is inconsistent", + Ships[i].ship_name); + } + + if (Ships[i].persona_index >= 0 && Ships[i].persona_index >= (int)Personas.size()) { + potential("Ship \"%s\" has an invalid persona index", Ships[i].ship_name); + } + + for (const auto& alt : Ships[i].s_alt_classes) { + if (alt.ship_class >= 0 && !SCP_vector_inbounds(Ship_info, alt.ship_class)) { + error("Ship \"%s\" has an invalid alternate class index", Ships[i].ship_name); + } + } + + int ai = Ships[i].ai_index; + if (ai < 0 || ai >= MAX_AI_INFO) { + return internal_error("AI index out of range for ship \"%s\"", Ships[i].ship_name); + } + + if (Ai_info[ai].shipnum != i) { + return internal_error("AI/ship references are corrupt"); + } + + if (Ai_info[ai].ai_class < 0 || Ai_info[ai].ai_class >= Num_ai_classes) { + error("Ship \"%s\" has an invalid AI class", Ships[i].ship_name); + } + + if (checkInitialOrders(Ai_info[ai].goals, i, -1) != 0) + return -1; + + SCP_set used_dockpoints; + for (dock_instance* dock_ptr = Objects[Ships[i].objnum].dock_list; dock_ptr != nullptr; + dock_ptr = dock_ptr->next) { + int obj = OBJ_INDEX(dock_ptr->docked_objp); + + if (!query_valid_object(obj)) { + return internal_error("Ship \"%s\" initially docked with non-existant ship", Ships[i].ship_name); + } + + if (Objects[obj].type != OBJ_SHIP && Objects[obj].type != OBJ_START) { + return internal_error("Ship \"%s\" initially docked with non-ship object", Ships[i].ship_name); + } + + int sp = get_ship_from_obj(obj); + if (!ship_docking_valid(i, sp) && !ship_docking_valid(sp, i)) { + return internal_error("Docking illegal between \"%s\" and \"%s\" (initially docked)", + Ships[i].ship_name, + Ships[sp].ship_name); + } + + auto dock_list = Editor::get_docking_list(Ship_info[Ships[i].ship_info_index].model_num); + int point = dock_ptr->dockpoint_used; + if (point < 0 || point >= (int)dock_list.size()) { + return internal_error("Invalid docker point (\"%s\" initially docked with \"%s\")", + Ships[i].ship_name, + Ships[sp].ship_name); + } else if (!used_dockpoints.insert(point).second) { + return internal_error("Ship \"%s\" has the same dockpoint used in multiple initial dock pairings", + Ships[i].ship_name); + } + + dock_list = Editor::get_docking_list(Ship_info[Ships[sp].ship_info_index].model_num); + point = dock_find_dockpoint_used_by_object(dock_ptr->docked_objp, &Objects[Ships[i].objnum]); + if (point < 0 || point >= (int)dock_list.size()) { + return internal_error("Invalid dockee point (\"%s\" initially docked with \"%s\")", + Ships[i].ship_name, + Ships[sp].ship_name); + } + } + + int w = Ships[i].wingnum; + bool is_in_loadout_screen = (z == OBJ_START); + if (!is_in_loadout_screen && w >= 0) { + if (multi && The_mission.game_type & MISSION_TYPE_MULTI_TEAMS) { + for (const char* tvt_wing_name : TVT_wing_names) { + if (!strcmp(Wings[w].name, tvt_wing_name)) { + is_in_loadout_screen = true; + break; + } + } + } else { + for (const char* starting_wing_name : Starting_wing_names) { + if (!strcmp(Wings[w].name, starting_wing_name)) { + is_in_loadout_screen = true; + break; + } + } + } + } + if (is_in_loadout_screen) { + int illegal = 0; + z = Ships[i].ship_info_index; + for (int primary_bank_weapon : Ships[i].weapons.primary_bank_weapons) { + if (primary_bank_weapon >= 0 + && !Ship_info[z].allowed_weapons[primary_bank_weapon]) { + illegal++; + } + } + + for (int secondary_bank_weapon : Ships[i].weapons.secondary_bank_weapons) { + if (secondary_bank_weapon >= 0 + && !Ship_info[z].allowed_weapons[secondary_bank_weapon]) { + illegal++; + } + } + + if (illegal) + error("%d illegal weapon(s) found on ship \"%s\"", illegal, Ships[i].ship_name); + } + + // Orders accepted must be a subset of the class's default orders — older missions + // sometimes saved orders from a different class. Auto-fix by discarding the extras. + { + const SCP_set& default_orders = + ship_get_default_orders_accepted(&Ship_info[Ships[i].ship_info_index]); + SCP_set extras; + std::set_difference(Ships[i].orders_accepted.begin(), Ships[i].orders_accepted.end(), + default_orders.begin(), default_orders.end(), + std::inserter(extras, extras.begin())); + if (!extras.empty()) { + if (_viewport->Error_checker_apply_auto_corrections) { + for (auto order : extras) + Ships[i].orders_accepted.erase(order); + } + warning("Ship \"%s\" accepts orders that are not part of its class's default orders.%s", + Ships[i].ship_name, + _viewport->Error_checker_apply_auto_corrections + ? " The extra orders have been removed." + : ""); + } + } + + } + } + + if (count != ship_get_num_ships()) { + return internal_error("num_ships is incorrect"); + } + + return 0; +} + +int ErrorChecker::checkWings() { + populateNames(); + + int count = 0; + for (int i = 0; i < MAX_WINGS; i++) { + int team = -1; + int j = Wings[i].wave_count; + if (j) { + count++; + if (j < 0 || j > MAX_SHIPS_PER_WING) { + return internal_error("Invalid number of ships in wing \"%s\"", Wings[i].name); + } + + while (j--) { + int obj = _viewport->editor->wing_objects[i][j]; + if (obj < 0 || obj >= MAX_OBJECTS) { + return internal_error("Wing_objects has an illegal object index"); + } + + if (!query_valid_object(obj)) { + return internal_error("Wing_objects references an unused object"); + } + + if (Objects[obj].type != OBJ_SHIP && Objects[obj].type != OBJ_START) { + return internal_error("Wing_objects of \"%s\" references an illegal object type", Wings[i].name); + } + int sp = Objects[obj].instance; + + char buf[256]; + wing_bash_ship_name(buf, Wings[i].name, j + 1); + if (stricmp(buf, Ships[sp].ship_name) != 0) { + return internal_error("Ship \"%s\" in wing should be called \"%s\"", + Ships[sp].ship_name, + buf); + } + + int ship_type = ship_query_general_type(sp); + if (ship_type < 0 || !(Ship_types[ship_type].flags[Ship::Type_Info_Flags::AI_can_form_wing])) { + potential("Ship \"%s\" is an illegal type to be in a wing", Ships[sp].ship_name); + } + + if (Ships[sp].wingnum != i) { + return internal_error("Wing/ship references are corrupt"); + } + + if (sp != Wings[i].ship_index[j]) { + return internal_error("Ship/wing references are corrupt"); + } + + if (team < 0) { + team = Ships[sp].team; + } else if (team != Ships[sp].team && team < 999) { + potential("Ship teams mixed within same wing (\"%s\")", Wings[i].name); + } + } + + if ((Wings[i].special_ship < 0) || (Wings[i].special_ship >= Wings[i].wave_count)) { + return internal_error("Special ship out of range for \"%s\"", Wings[i].name); + } + + if (Wings[i].num_waves < 0) { + return internal_error("Number of waves for \"%s\" is negative", Wings[i].name); + } + + if (Wings[i].threshold < 0) { + return internal_error("Threshold for \"%s\" is invalid", Wings[i].name); + } + + if (Wings[i].threshold + Wings[i].wave_count > MAX_SHIPS_PER_WING) { + if (_viewport->Error_checker_apply_auto_corrections) { + Wings[i].threshold = MAX_SHIPS_PER_WING - Wings[i].wave_count; + warning("Threshold for wing \"%s\" is higher than allowed. Reset to %d", + Wings[i].name, + Wings[i].threshold); + } else { + warning("Threshold for wing \"%s\" is higher than allowed.", Wings[i].name); + } + } + + for (j = 0; j < obj_count; j++) { + if (names[j]) { + if (!stricmp(names[j], Wings[i].name)) { + return internal_error("Wing name is also used by an object (%s)", names[j]); + } + } + } + + if (fred_check_sexp(Wings[i].arrival_cue, OPR_BOOL, "arrival cue of wing \"%s\"", Wings[i].name)) { + return -1; + } + + if (fred_check_sexp(Wings[i].departure_cue, OPR_BOOL, "departure cue of wing \"%s\"", Wings[i].name)) { + return -1; + } + + if (Wings[i].arrival_location != ArrivalLocation::AT_LOCATION) { + if (!Wings[i].arrival_anchor.isValid()) { + error("Wing \"%s\" requires a valid arrival target", Wings[i].name); + } + if (Wings[i].arrival_location == ArrivalLocation::FROM_DOCK_BAY) { + SCP_string anchor_message; + check_anchor_for_hangar_bay(anchor_message, _anchors_checked, Wings[i].arrival_anchor, Wings[i].name, false, true); + if (!anchor_message.empty()) + error("%s", anchor_message.c_str()); + } + } + + if (Wings[i].departure_location != DepartureLocation::AT_LOCATION) { + if (!Wings[i].departure_anchor.isValid()) { + error("Wing \"%s\" requires a valid departure target", Wings[i].name); + } + if (Wings[i].departure_location == DepartureLocation::TO_DOCK_BAY) { + SCP_string anchor_message; + check_anchor_for_hangar_bay(anchor_message, _anchors_checked, Wings[i].departure_anchor, Wings[i].name, false, false); + if (!anchor_message.empty()) + error("%s", anchor_message.c_str()); + } + } + + if (Wings[i].arrival_delay < 0) { + error("Wing \"%s\" has a negative arrival delay", Wings[i].name); + } + + if (Wings[i].departure_delay < 0) { + error("Wing \"%s\" has a negative departure delay", Wings[i].name); + } + + if (Wings[i].arrival_location != ArrivalLocation::AT_LOCATION && Wings[i].arrival_distance <= 0) { + error("Arrival distance for wing \"%s\" must be greater than 0", Wings[i].name); + } + + if (Wings[i].formation >= 0 && Wings[i].formation >= (int)Wing_formations.size()) { + potential("Wing \"%s\" has an invalid formation", Wings[i].name); + } + + { + bool has_player = false; + for (int k = 0; k < Wings[i].wave_count; k++) { + if (Objects[Ships[Wings[i].ship_index[k]].objnum].type == OBJ_START) { + has_player = true; + break; + } + } + if (has_player && Wings[i].arrival_delay > 0) { + potential("Wing \"%s\" contains a player start but has a non-zero arrival delay; the delay will be ignored", + Wings[i].name); + } + } + + if (checkInitialOrders(Wings[i].ai_goals, -1, i) != 0) + return -1; + } + } + + if (count != Num_wings) { + return internal_error("Num_wings is incorrect"); + } + + return 0; +} + +int ErrorChecker::checkWaypointPaths() { + populateNames(); + + for (const auto& ii : Waypoint_lists) { + for (int z = 0; z < obj_count; z++) { + if (names[z]) { + if (!stricmp(names[z], ii.get_name())) { + return internal_error("Waypoint path name is also used by an object (%s)", names[z]); + } + } + } + + for (const auto& jj : ii.get_waypoints()) { + char buf[256]; + waypoint_stuff_name(buf, jj); + int z; + for (z = 0; z < obj_count; z++) { + if (names[z]) { + if (!stricmp(names[z], buf)) { + break; + } + } + } + + if (z == obj_count) { + return internal_error("Waypoint \"%s\" not linked to an object", buf); + } + } + } + + return 0; +} + +int ErrorChecker::checkPlayerStarts() { + int multi = (The_mission.game_type & MISSION_TYPE_MULTI) ? 1 : 0; + + if (Player_starts > MAX_PLAYERS) { + return internal_error("Number of player starts exceeds max limit"); + } + + if (!multi && (Player_starts > 1)) { + error("Multiple player starts exist, but this is a single player mission"); + } + + return 0; +} + +int ErrorChecker::checkReinforcements() { + + if (Num_reinforcements > MAX_REINFORCEMENTS) { + return internal_error("Number of reinforcements exceeds max limit"); + } + + for (int i = 0; i < Num_reinforcements; i++) { + if (Reinforcements[i].arrival_delay < 0) { + error("Reinforcement \"%s\" has a negative arrival delay", Reinforcements[i].name); + } + + int z = 0; + int ship_wingnum = -1; + for (const auto& ship : Ships) { + if ((ship.objnum >= 0) && !stricmp(ship.ship_name, Reinforcements[i].name)) { + z = 1; + ship_wingnum = ship.wingnum; + break; + } + } + + for (const auto& wing : Wings) { + if (wing.wave_count && !stricmp(wing.name, Reinforcements[i].name)) { + z = 1; + break; + } + } + + if (!z) { + return internal_error("Reinforcement name not found in ships or wings"); + } + + if (ship_wingnum >= 0) { + potential("Reinforcement \"%s\" is a ship that belongs to a wing; the reinforcement flag will be ignored", + Reinforcements[i].name); + } + } + + return 0; +} + +int ErrorChecker::checkPlayerWings() { + Assert((Player_start_shipnum >= 0) && (Player_start_shipnum < MAX_SHIPS) && (Ships[Player_start_shipnum].objnum >= 0)); // NOLINT(readability-simplify-boolean-expr) + + const int multi = (The_mission.game_type & MISSION_TYPE_MULTI) ? 1 : 0; + + // The first starting wing and the first TVT wing must share a name — missionparse/post_process_ships_wings + // treats this as a fatal Error() in-game, so the mission will not run otherwise. + if (strcmp(Starting_wing_names[0], TVT_wing_names[0]) != 0) { + error("The first starting wing (\"%s\") and the first team-versus-team wing (\"%s\") must have the same name", + Starting_wing_names[0], TVT_wing_names[0]); + } + + auto checkMixedSpecies = [this](int w) { + int species = -1; + bool mixed = false; + for (int i = 0; i < Wings[w].wave_count; i++) { + int s = Wings[w].ship_index[i]; + if (species < 0) + species = Ship_info[Ships[s].ship_info_index].species; + else if (Ship_info[Ships[s].ship_info_index].species != species) + mixed = true; + } + if (mixed) + error("%s wing must all be of the same species", Wings[w].name); + }; + + // In non-TVT multiplayer, squadron wings must be contiguous from the front — if wing[i] exists + // but wing[i-1] doesn't, wings can disappear at runtime (missionparse.cpp line 5605). + if (multi && !(The_mission.game_type & MISSION_TYPE_MULTI_TEAMS)) { + bool found[MAX_STARTING_WINGS] = {}; + for (int i = 0; i < MAX_STARTING_WINGS; i++) { + for (const auto& wing : Wings) { + if (wing.wave_count && !strcmp(wing.name, Squadron_wing_names[i])) { + found[i] = true; + break; + } + } + } + for (int i = 1; i < MAX_STARTING_WINGS; i++) { + if (found[i] && !found[i - 1]) { + potential("Squadron wings are not in the correct order: wing \"%s\" exists but \"%s\" does not; this may cause wings to disappear in multiplayer", + Squadron_wing_names[i], Squadron_wing_names[i - 1]); + } + } + } + + if (multi && The_mission.game_type & MISSION_TYPE_MULTI_TEAMS) { + for (const char* tvt_wing_name : TVT_wing_names) { + if (ship_tvt_wing_lookup(tvt_wing_name) == -1) { + error("%s wing is required for multiplayer team vs. team missions", tvt_wing_name); + } + } + } + + if (multi) { + if (The_mission.game_type & MISSION_TYPE_MULTI_TEAMS) { + for (int i = 0; i < MAX_TVT_WINGS; i++) { + if (TVT_wings[i] >= 0 && Wings[TVT_wings[i]].num_waves > 1) { + if (_viewport->Error_checker_apply_auto_corrections) + Wings[TVT_wings[i]].num_waves = 1; + error("%s wing must contain only 1 wave.%s", TVT_wing_names[i], + _viewport->Error_checker_apply_auto_corrections + ? "\nThis change has been made for you." + : ""); + } + } + } else { + for (int i = 0; i < MAX_STARTING_WINGS; i++) { + if (Starting_wings[i] >= 0 && Wings[Starting_wings[i]].num_waves > 1) { + if (_viewport->Error_checker_apply_auto_corrections) + Wings[Starting_wings[i]].num_waves = 1; + error("%s wing must contain only 1 wave.%s", Starting_wing_names[i], + _viewport->Error_checker_apply_auto_corrections + ? "\nThis change has been made for you." + : ""); + } + } + } + } + + if (multi && The_mission.game_type & MISSION_TYPE_MULTI_TEAMS) { + for (int i = 0; i < MAX_TVT_WINGS; i++) { + if (TVT_wings[i] >= 0 && Wings[TVT_wings[i]].wave_count > 4) { + error("%s wing has too many ships. Should only have 4 max.", TVT_wing_names[i]); + } + } + } else { + for (int i = 0; i < MAX_STARTING_WINGS; i++) { + if (Starting_wings[i] >= 0 && Wings[Starting_wings[i]].wave_count > 4) { + error("%s wing has too many ships. Should only have 4 max.", Starting_wing_names[i]); + } + } + } + + if (multi && The_mission.game_type & MISSION_TYPE_MULTI_TEAMS) { + for (int i = 0; i < MAX_TVT_WINGS; i++) { + if (TVT_wings[i] >= 0 && Wings[TVT_wings[i]].arrival_delay > 0) { + potential("%s wing shouldn't have a non-zero arrival delay", TVT_wing_names[i]); + } + } + } + + if (multi) { + if (The_mission.game_type & MISSION_TYPE_MULTI_TEAMS) { + for (int tvt_wing : TVT_wings) { + if (tvt_wing >= 0) { + checkMixedSpecies(tvt_wing); + } + } + } else { + for (int starting_wing : Starting_wings) { + if (starting_wing >= 0) { + checkMixedSpecies(starting_wing); + } + } + } + } + + int starting_wing_count[MAX_STARTING_WINGS]; + int tvt_wing_count[MAX_TVT_WINGS]; + SCP_string starting_wing_list; + SCP_string tvt_wing_list; + + for (int i = 0; i < MAX_STARTING_WINGS; i++) { + starting_wing_count[i] = 0; + + if (i < MAX_STARTING_WINGS - 1) { + starting_wing_list += Starting_wing_names[i]; + if (MAX_STARTING_WINGS > 2) + starting_wing_list += ","; + starting_wing_list += " "; + } else { + starting_wing_list += "or "; + starting_wing_list += Starting_wing_names[i]; + } + } + for (int i = 0; i < MAX_TVT_WINGS; i++) { + tvt_wing_count[i] = 0; + + if (i < MAX_TVT_WINGS - 1) { + tvt_wing_list += TVT_wing_names[i]; + if (MAX_TVT_WINGS > 2) + tvt_wing_list += ","; + tvt_wing_list += " "; + } else { + tvt_wing_list += "or "; + tvt_wing_list += TVT_wing_names[i]; + } + } + + object* ptr = GET_FIRST(&obj_used_list); + while (ptr != END_OF_LIST(&obj_used_list)) { + int ship_instance = ptr->instance; + int err = 0; + + if (ptr->type == OBJ_START) { + int z = Ships[ship_instance].wingnum; + if (z < 0) { + err = 1; + } else { + int in_starting_wing = 0; + int in_tvt_wing = 0; + + for (int i = 0; i < MAX_STARTING_WINGS; i++) { + if (Starting_wings[i] == z) { + in_starting_wing = 1; + starting_wing_count[i]++; + } + } + for (int i = 0; i < MAX_TVT_WINGS; i++) { + if (TVT_wings[i] == z) { + in_tvt_wing = 1; + tvt_wing_count[i]++; + } + } + + if (The_mission.game_type & MISSION_TYPE_MULTI_TEAMS) { + if (!in_tvt_wing) + err = 1; + } else { + if (!in_starting_wing) + err = 1; + } + } + + if (err) { + if (The_mission.game_type & MISSION_TYPE_MULTI_TEAMS) { + error("Player %s should be part of %s wing", Ships[ship_instance].ship_name, tvt_wing_list.c_str()); + } else { + error("Player %s should be part of %s wing", Ships[ship_instance].ship_name, starting_wing_list.c_str()); + } + } + } + + ptr = GET_NEXT(ptr); + } + + if (The_mission.game_type & MISSION_TYPE_MULTI_TEAMS) { + for (int i = 0; i < MAX_TVT_WINGS; i++) { + if (!tvt_wing_count[i]) { + error("%s wing doesn't contain any players (which it should)", TVT_wing_names[i]); + } + } + } + + return 0; +} + +int ErrorChecker::checkMissionEvents() { + for (const auto& event : Mission_events) { + if (event.repeat_count == 0) { + error("Mission event \"%s\" has a repeat count of 0; must be at least 1 (or negative for unlimited)", + event.name.c_str()); + } + + if (event.trigger_count == 0) { + error("Mission event \"%s\" has a trigger count of 0; must be at least 1 (or negative for unlimited)", + event.name.c_str()); + } + + if (fred_check_sexp(event.formula, OPR_NULL, "mission event \"%s\"", event.name.c_str())) { + return -1; + } + } + return 0; +} + +int ErrorChecker::checkMissionGoals() { + for (const auto& goal : Mission_goals) { + if (fred_check_sexp(goal.formula, OPR_BOOL, "mission goal \"%s\"", goal.name.c_str())) { + return -1; + } + } + return 0; +} + +int ErrorChecker::checkBriefings() { + + for (int bs = 0; bs < Num_teams; bs++) { + for (int s = 0; s < Briefings[bs].num_stages; s++) { + brief_stage* sp = &Briefings[bs].stages[s]; + + if (fred_check_sexp(sp->formula, OPR_BOOL, "briefing stage %d (team %d)", s + 1, bs + 1)) { + return -1; + } + + int t = sp->num_icons; + for (int i = 0; i < t - 1; i++) { + for (int j = i + 1; j < t; j++) { + if ((sp->icons[i].id > 0) && (sp->icons[i].id == sp->icons[j].id)) { + potential("Duplicate icon IDs %d in briefing stage %d", sp->icons[i].id, s + 1); + } + } + } + } + } + return 0; +} + +int ErrorChecker::checkDebriefings() { + for (int j = 0; j < Num_teams; j++) { + for (int i = 0; i < Debriefings[j].num_stages; i++) { + if (fred_check_sexp(Debriefings[j].stages[i].formula, OPR_BOOL, "debriefing stage %d", i + 1)) { + return -1; + } + } + } + return 0; +} + +int ErrorChecker::checkWingOrders() { + + for (const auto& wing : Wings) { + if (!wing.wave_count) { + continue; + } + + int starting_wing = (ship_starting_wing_lookup(wing.name) != -1); + + if (starting_wing && (wing.flags[Ship::Wing_Flags::Reinforcement])) { + error("Starting Wing %s marked as reinforcement. This wing\nshould either be renamed, or unmarked as reinforcement.", + wing.name); + } + + std::set default_orders; + int default_orders_idx = -1; + for (int j = 0; j < wing.wave_count; j++) { + if (Objects[Ships[wing.ship_index[j]].objnum].type == OBJ_START) { + continue; + } + + const std::set& orders = Ships[wing.ship_index[j]].orders_accepted; + + if (default_orders_idx < 0) { + default_orders_idx = j; + default_orders = orders; + } else if (default_orders != orders) { + potential("%s and %s will accept different orders. All ships in a wing must accept the same Player Orders.", + Ships[wing.ship_index[j]].ship_name, + Ships[wing.ship_index[default_orders_idx]].ship_name); + } + } + } + + return 0; +} + +int ErrorChecker::checkAsteroidTargets() { + for (const auto& name : Asteroid_field.target_names) { + if (ship_name_lookup(name.c_str(), 1) < 0) { + error("Asteroid target '%s' is not a valid ship", name.c_str()); + } + } + return 0; +} + +int ErrorChecker::checkDockingGroupCues() { + SCP_set visited; // ship indices already accounted for + + for (int i = 0; i < MAX_SHIPS; i++) { + if (Ships[i].objnum < 0) continue; + if (!query_valid_object(Ships[i].objnum)) continue; + if (!object_is_docked(&Objects[Ships[i].objnum])) continue; + if (visited.count(i)) continue; + + // BFS to collect all ships in this docking group + SCP_vector group; + SCP_vector queue = {i}; + while (!queue.empty()) { + int si = queue.back(); + queue.pop_back(); + if (visited.count(si)) continue; + visited.insert(si); + group.push_back(si); + + for (dock_instance* dp = Objects[Ships[si].objnum].dock_list; dp != nullptr; dp = dp->next) { + int docked_obj = OBJ_INDEX(dp->docked_objp); + if (Objects[docked_obj].type == OBJ_SHIP || Objects[docked_obj].type == OBJ_START) { + int docked_ship = Objects[docked_obj].instance; + if (!visited.count(docked_ship)) + queue.push_back(docked_ship); + } + } + } + + // Count non-false arrival cues; winged ships share the wing cue + SCP_set checked_wings; + int non_false_count = 0; + for (int si : group) { + int w = Ships[si].wingnum; + if (w >= 0 && w < MAX_WINGS) { + if (!checked_wings.insert(w).second) continue; + if (Wings[w].arrival_cue != Locked_sexp_false) + non_false_count++; + } else { + if (Ships[si].arrival_cue != Locked_sexp_false) + non_false_count++; + } + } + + if (non_false_count == 0) { + error("Docking group containing \"%s\" has no ship with a non-false arrival cue; the group will not appear in-mission", + Ships[i].ship_name); + } else if (non_false_count > 1) { + warning("Docking group containing \"%s\" has more than one non-false arrival cue; only one is allowed", + Ships[i].ship_name); + } + } + + return 0; +} + +int ErrorChecker::checkTeamLoadout() { + for (int i = 0; i < Num_teams; i++) { + // Respect the per-team "Bypass Loadout Validation" flag (set in the loadout editor). + if (Team_data[i].do_not_validate) + continue; + + // Build a fresh usage list for this team's starting wings. + int usage[MAX_WEAPON_TYPES]; + _viewport->editor->generate_team_weaponry_usage_list(i, usage); + + // Zero out weapons that are accounted for in the loadout pool, so that + // only weapons missing from the pool remain non-zero. + for (int j = 0; j < Team_data[i].num_weapon_choices; j++) { + int wi = Team_data[i].weaponry_pool[j]; + if (wi >= 0 && wi < MAX_WEAPON_TYPES) + usage[wi] = 0; + } + + // Any non-zero entry is a weapon used in wings but absent from the loadout. + for (int j = 0; j < MAX_WEAPON_TYPES; j++) { + if (usage[j] <= 0) + continue; + + if (_viewport->Error_checker_apply_auto_corrections && Team_data[i].num_weapon_choices < MAX_WEAPON_TYPES) { + // Add the missing weapon to the pool so the mission is structurally valid. + int slot = Team_data[i].num_weapon_choices; + Team_data[i].weaponry_pool[slot] = j; + Team_data[i].weaponry_count[slot] = usage[j]; + strcpy_s(Team_data[i].weaponry_amount_variable[slot], ""); + strcpy_s(Team_data[i].weaponry_pool_variable[slot], ""); + Team_data[i].num_weapon_choices++; + error("Weapon \"%s\" is used in wings of team %d but was not in the team loadout pool. Added automatically.", + Weapon_info[j].name, i + 1); + } else { + error("Weapon \"%s\" is used in wings of team %d but is not in the team loadout pool", + Weapon_info[j].name, i + 1); + } + } + } + return 0; +} + +int ErrorChecker::checkInitialOrders(ai_goal* goals, int ship, int wing) { + Assertion(_viewport->editor != nullptr, "checkInitialOrders requires a valid editor"); + + auto get_order_name = [](ai_goal_mode order) -> const char* { + if (order == AI_GOAL_NONE) + return "None"; + const ai_goal_list* list = Editor::getAi_goal_list(); + int size = Editor::getAigoal_list_size(); + for (int i = 0; i < size; i++) + if (list[i].def == order) + return list[i].name; + return "???"; + }; + + int team, team2; + char* source; + const char* entity = (ship >= 0) ? "ship" : "wing"; + + if (ship >= 0) { + source = Ships[ship].ship_name; + team = Ships[ship].team; + for (int i = 0; i < MAX_AI_GOALS; i++) { + if (!ai_query_goal_valid(ship, goals[i].ai_mode)) + potential("Order \"%s\" isn't allowed for ship \"%s\"", get_order_name(goals[i].ai_mode), source); + } + } else { + Assert(wing >= 0); + Assert(Wings[wing].wave_count > 0); + source = Wings[wing].name; + team = Ships[Objects[_viewport->editor->wing_objects[wing][0]].instance].team; + for (int j = 0; j < Wings[wing].wave_count; j++) { + for (int i = 0; i < MAX_AI_GOALS; i++) { + if (!ai_query_goal_valid(Wings[wing].ship_index[j], goals[i].ai_mode)) + potential("Order \"%s\" isn't allowed for ship \"%s\"", get_order_name(goals[i].ai_mode), + Ships[Wings[wing].ship_index[j]].ship_name); + } + } + } + + int flag, found; + for (int i = 0; i < MAX_AI_GOALS; i++) { + switch (goals[i].ai_mode) { + case AI_GOAL_NONE: + case AI_GOAL_CHASE_ANY: + case AI_GOAL_CHASE_SHIP_CLASS: + case AI_GOAL_UNDOCK: + case AI_GOAL_KEEP_SAFE_DISTANCE: + case AI_GOAL_PLAY_DEAD: + case AI_GOAL_PLAY_DEAD_PERSISTENT: + case AI_GOAL_WARP: + flag = 0; + break; + + case AI_GOAL_WAYPOINTS: + case AI_GOAL_WAYPOINTS_ONCE: + flag = 1; + break; + + case AI_GOAL_DOCK: + if (ship < 0) { + error("Initial orders error for wing \"%s\"\n\nWings can't dock", source); + continue; + } + FALLTHROUGH; + + case AI_GOAL_DESTROY_SUBSYSTEM: + case AI_GOAL_CHASE: + case AI_GOAL_GUARD: + case AI_GOAL_DISARM_SHIP: + case AI_GOAL_DISARM_SHIP_TACTICAL: + case AI_GOAL_DISABLE_SHIP: + case AI_GOAL_DISABLE_SHIP_TACTICAL: + case AI_GOAL_EVADE_SHIP: + case AI_GOAL_STAY_NEAR_SHIP: + case AI_GOAL_FORM_ON_WING: + case AI_GOAL_IGNORE: + case AI_GOAL_IGNORE_NEW: + flag = 2; + break; + + case AI_GOAL_CHASE_WING: + case AI_GOAL_GUARD_WING: + flag = 3; + break; + + case AI_GOAL_STAY_STILL: + flag = 4; + break; + + default: + return internal_error("Initial orders error for %s \"%s\"\n\nInvalid goal type", entity, source); + } + + found = 0; + if (flag > 0) { + if (*goals[i].target_name == '<') { + error("Initial orders error for %s \"%s\"\n\nInvalid target", entity, source); + continue; + } + + if (!stricmp(goals[i].target_name, source)) { + error("Initial orders error for %s \"%s\"\n\nTarget of %s's goal is itself", entity, source, entity); + continue; + } + } + + int inst = team2 = -1; + if (flag == 1) { + if (find_matching_waypoint_list(goals[i].target_name) == nullptr) + return internal_error("Initial orders error for %s \"%s\"\n\nInvalid target waypoint path name", entity, source); + + } else if (flag == 2) { + object* ptr = GET_FIRST(&obj_used_list); + while (ptr != END_OF_LIST(&obj_used_list)) { + if (ptr->type == OBJ_SHIP || ptr->type == OBJ_START) { + inst = ptr->instance; + if (!stricmp(goals[i].target_name, Ships[inst].ship_name)) { + found = 1; + break; + } + } + ptr = GET_NEXT(ptr); + } + + if (!found) + return internal_error("Initial orders error for %s \"%s\"\n\nInvalid target ship name", entity, source); + + if (wing >= 0) { + if (Ships[inst].wingnum == wing && Objects[Ships[inst].objnum].type != OBJ_START) { + error("Initial orders error for wing \"%s\"\n\nTarget ship of wing's goal is within said wing", source); + continue; + } + } + + team2 = Ships[inst].team; + + } else if (flag == 3) { + int j; + for (j = 0; j < MAX_WINGS; j++) + if (Wings[j].wave_count && !stricmp(Wings[j].name, goals[i].target_name)) + break; + + if (j >= MAX_WINGS) + return internal_error("Initial orders error for %s \"%s\"\n\nInvalid target wing name", entity, source); + + if (ship >= 0) { + if (Ships[ship].wingnum == j) { + error("Initial orders error for ship \"%s\"\n\nTarget wing of ship's goal is same wing said ship is part of", source); + continue; + } + } + + team2 = Ships[Objects[_viewport->editor->wing_objects[j][0]].instance].team; + + } else if (flag == 4) { + object* ptr = GET_FIRST(&obj_used_list); + while (ptr != END_OF_LIST(&obj_used_list)) { + if (ptr->type == OBJ_SHIP || ptr->type == OBJ_START) { + inst = ptr->instance; + if (!stricmp(goals[i].target_name, Ships[inst].ship_name)) { + found = 2; + break; + } + } else if (ptr->type == OBJ_WAYPOINT) { + if (!stricmp(goals[i].target_name, object_name(OBJ_INDEX(ptr)))) { + found = 1; + break; + } + } + ptr = GET_NEXT(ptr); + } + + if (!found) + return internal_error("Initial orders error for %s \"%s\"\n\nInvalid target ship or waypoint name", entity, source); + + if (found == 2) { + if (wing >= 0) { + if (Ships[inst].wingnum == wing && Objects[Ships[inst].objnum].type != OBJ_START) { + error("Initial orders error for wing \"%s\"\n\nTarget ship of wing's goal is within said wing", source); + continue; + } + } + team2 = Ships[inst].team; + } + } + + switch (goals[i].ai_mode) { + case AI_GOAL_DESTROY_SUBSYSTEM: + Assert(flag == 2 && inst >= 0); // NOLINT(readability-simplify-boolean-expr) + if (ship_find_subsys(&Ships[inst], goals[i].docker.name) < 0) { + potential("Initial orders error for ship \"%s\"\n\nUnknown subsystem type", source); + continue; + } + break; + + case AI_GOAL_DOCK: { + int dock1 = -1, dock2 = -1, model1, model2; + + Assert(flag == 2 && inst >= 0); // NOLINT(readability-simplify-boolean-expr) + if (!ship_docking_valid(ship, inst)) { + error("Initial orders error for ship \"%s\"\n\nDocking illegal between given ship types", source); + continue; + } + + model1 = Ship_info[Ships[ship].ship_info_index].model_num; + auto model1Docks = Editor::get_docking_list(model1); + for (int j = 0; j < (int)model1Docks.size(); ++j) { + if (!stricmp(goals[i].docker.name, model1Docks[j].c_str())) { + dock1 = j; + break; + } + } + + model2 = Ship_info[Ships[inst].ship_info_index].model_num; + auto model2Docks = Editor::get_docking_list(model2); + for (int j = 0; j < (int)model2Docks.size(); ++j) { + if (!stricmp(goals[i].dockee.name, model2Docks[j].c_str())) { + dock2 = j; + break; + } + } + + if (dock1 < 0) { + error("Initial orders error for ship \"%s\"\n\nInvalid docker point", source); + continue; + } + + if (dock2 < 0) { + error("Initial orders error for ship \"%s\"\n\nInvalid dockee point", source); + continue; + } + + if (!(model_get_dock_index_type(model1, dock1) & model_get_dock_index_type(model2, dock2))) { + error("Initial orders error for ship \"%s\"\n\nDock points are incompatible", source); + } + + break; + } + + default: + break; + } + + switch (goals[i].ai_mode) { + case AI_GOAL_GUARD: + case AI_GOAL_GUARD_WING: + if (team != team2) + potential("Initial orders error for %s \"%s\"\n\n%s assigned to guard a different team", + entity, source, ship >= 0 ? "Ship" : "Wing"); + break; + + case AI_GOAL_CHASE: + case AI_GOAL_CHASE_WING: + case AI_GOAL_DESTROY_SUBSYSTEM: + case AI_GOAL_DISARM_SHIP: + case AI_GOAL_DISARM_SHIP_TACTICAL: + case AI_GOAL_DISABLE_SHIP: + case AI_GOAL_DISABLE_SHIP_TACTICAL: + if (team == team2) + potential("Initial orders error for %s \"%s\"\n\n%s assigned to attack same team", + entity, source, ship >= 0 ? "Ship" : "Wing"); + break; + + default: + break; + } + } + + return 0; +} + +} // namespace fso::fred diff --git a/qtfred/src/ui/util/ErrorChecker.h b/qtfred/src/ui/util/ErrorChecker.h new file mode 100644 index 00000000000..ee139ba3ab2 --- /dev/null +++ b/qtfred/src/ui/util/ErrorChecker.h @@ -0,0 +1,145 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace fso::fred { + +class EditorViewport; + +enum class ErrorSeverity { InternalError, Error, Warning, Potential }; + +// Display metadata for one severity level. +// Colors are stored as plain RGB components so this header stays Qt-free; +// callers that need a QColor construct one from (r, g, b) at the use site. +struct SeverityInfo { + uint8_t r, g, b; + const char* label; + const char* tooltip; +}; + +// One entry per ErrorSeverity value, index-matched to the enum. +inline constexpr std::array severity_info = {{ + { 0xCC, 0x33, 0x33, "Critical Error", + "A serious structural problem FRED cannot automatically correct. " + "The mission may fail to load or behave unpredictably in-game." }, + { 0xE0, 0x78, 0x30, "Error", + "A mission design problem that must be fixed. The mission may not play correctly." }, + { 0xD4, 0xA0, 0x00, "Warning", + "A configuration issue that may cause unexpected behavior in-game." }, + { 0x40, 0x80, 0xCC, "Potential Issue", + "A situation that may be intentional but is worth reviewing." }, +}}; + +inline const SeverityInfo& infoFor(ErrorSeverity sev) { + switch (sev) { + case ErrorSeverity::InternalError: return severity_info[0]; + case ErrorSeverity::Error: return severity_info[1]; + case ErrorSeverity::Warning: return severity_info[2]; + case ErrorSeverity::Potential: return severity_info[3]; + } + UNREACHABLE("Unhandled ErrorSeverity value"); + return severity_info[1]; +} + +struct ErrorEntry { + SCP_string message; + ErrorSeverity severity; +}; + +enum class ErrorCheckType { + InitialOrders, // standalone check (used by ShipGoalsDialogModel) + ObjectList, // object integrity + names[] population + Ships, // ship SEXPs, anchors, AI goals, docking, loadout weapons + Wings, // wing structure, SEXPs, anchors, AI goals, thresholds + WaypointPaths, // waypoint path name conflicts with objects + PlayerStarts, // player start count validity + Reinforcements, // reinforcement name references + PlayerWings, // player wing membership and wave constraints + MissionEvents, // mission event SEXP validation + MissionGoals, // mission goal SEXP validation + Briefings, // briefing icon ID duplicates + Debriefings, // debriefing SEXP validation + WingOrders, // wing reinforcement flags and accepted orders consistency + AsteroidTargets, // asteroid field target ship name validity + DockingGroupCues, // initially-docked groups must have exactly one non-false arrival cue + TeamLoadout, // weapons used in starting wings but absent from the team loadout pool +}; + +struct ErrorCheckContext { + ai_goal* goals = nullptr; + int ship = -1; + int wing = -1; +}; + +class ErrorChecker { +public: + explicit ErrorChecker(EditorViewport* viewport); + + // Run all checks in collect mode; returns true if errors were found + bool runFullCheck(); + + // Run a specific check in collect mode; returns true if errors were found. + // Errors are available via getErrors() after the call. + bool runCheck(ErrorCheckType type, const ErrorCheckContext& ctx = {}); + + const SCP_vector& getErrors() const; + +private: + EditorViewport* _viewport; + + char* names[MAX_OBJECTS]; + char err_flags[MAX_OBJECTS]; + int obj_count = 0; + // Accumulates whether any error() or warning() has fired during a check run. + // Those helpers are void (no return value to propagate), so g_err is the only + // way runFullCheck/runCheck can report non-critical issues that don't cause an + // early abort. internal_error() sets it too, but also returns -1 for callers + // that need to halt immediately. + int g_err = 0; + SCP_vector _collected_errors; + SCP_set _anchors_checked; + + // error() records a user-fixable problem and continues; return type is void so + // callers cannot short-circuit on it (use internal_error for abort-worthy issues). + void error(SCP_FORMAT_STRING const char* msg, ...) SCP_FORMAT_STRING_ARGS(2, 3); + // internal_error() records a data-integrity problem and returns -1 so callers + // can propagate an early abort when continuing would be unsafe. + int internal_error(SCP_FORMAT_STRING const char* msg, ...) SCP_FORMAT_STRING_ARGS(2, 3); + void warning(SCP_FORMAT_STRING const char* msg, ...) SCP_FORMAT_STRING_ARGS(2, 3); + void potential(SCP_FORMAT_STRING const char* msg, ...) SCP_FORMAT_STRING_ARGS(2, 3); + int fred_check_sexp(int sexp, int type, const char* location, ...); + + // Populates names[], err_flags[], and obj_count from the object list. + // Safe to call multiple times; frees any previously allocated waypoint name strings first. + // Called internally by checkWings() and checkWaypointPaths() — no external call needed. + void populateNames(); + + // Individual check methods (called by runFullCheck in order). + // Return 0 on success or when only user-fixable errors were found. + // Return -1 on internal errors severe enough to warrant aborting further checks. + int checkObjectList(); + int checkShips(); + int checkWings(); + int checkWaypointPaths(); + int checkPlayerStarts(); + int checkReinforcements(); + int checkPlayerWings(); + int checkMissionEvents(); + int checkMissionGoals(); + int checkBriefings(); + int checkDebriefings(); + int checkWingOrders(); + int checkAsteroidTargets(); + int checkDockingGroupCues(); + int checkTeamLoadout(); + + // Helper methods + int checkInitialOrders(ai_goal* goals, int ship, int wing); +}; + +} // namespace fso::fred diff --git a/qtfred/ui/ErrorCheckerDialog.ui b/qtfred/ui/ErrorCheckerDialog.ui new file mode 100644 index 00000000000..30e86d746e0 --- /dev/null +++ b/qtfred/ui/ErrorCheckerDialog.ui @@ -0,0 +1,97 @@ + + + fso::fred::dialogs::ErrorCheckerDialog + + + + 0 + 0 + 600 + 480 + + + + Error Checker + + + + + + + + Rerun + + + + + + + Check Potential Issues + + + + + + + Apply Auto-Corrections + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + true + + + + + + + + + No check has been run yet. + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Close + + + + + + + + + + diff --git a/qtfred/ui/PreferencesDialog.ui b/qtfred/ui/PreferencesDialog.ui index c11c0989a21..e73b35564aa 100644 --- a/qtfred/ui/PreferencesDialog.ui +++ b/qtfred/ui/PreferencesDialog.ui @@ -107,10 +107,26 @@ + + + + + + + Error Checker + + + + + + Check potential issues + + + - + - Error checker checks for potential issues + Apply auto-corrections diff --git a/test/src/test_stubs.cpp b/test/src/test_stubs.cpp index 2970ae133db..de2b3971723 100644 --- a/test/src/test_stubs.cpp +++ b/test/src/test_stubs.cpp @@ -33,6 +33,7 @@ UI_TIMESTAMP Multi_ping_timestamp; int Sun_drew = 0; int Fred_running = 0; +int Qtfred_running = 0; float Sun_spot = 0.0f; char Fred_alt_names[MAX_SHIPS][NAME_LENGTH+1]; char Fred_callsigns[MAX_SHIPS][NAME_LENGTH+1];