Index: src/wp-includes/class-wpdb.php
===================================================================
--- src/wp-includes/class-wpdb.php	(revision 54725)
+++ src/wp-includes/class-wpdb.php	(working copy)
@@ -655,41 +655,6 @@
 	);
 
 	/**
-	 * Backward compatibility, where wpdb::prepare() has not quoted formatted/argnum placeholders.
-	 *
-	 * Historically this could be used for table/field names, or for some string formatting, e.g.
-	 *
-	 *     $wpdb->prepare( 'WHERE `%1s` = "%1s something %1s" OR %1$s = "%-10s"', 'field_1', 'a', 'b', 'c' );
-	 *
-	 * But it's risky, e.g. forgetting to add quotes, resulting in SQL Injection vulnerabilities:
-	 *
-	 *     $wpdb->prepare( 'WHERE (id = %1s) OR (id = %2$s)', $_GET['id'], $_GET['id'] ); // ?id=id
-	 *
-	 * This feature is preserved while plugin authors update their code to use safer approaches:
-	 *
-	 *     $wpdb->prepare( 'WHERE %1s = %s', $_GET['key'], $_GET['value'] );
-	 *     $wpdb->prepare( 'WHERE %i  = %s', $_GET['key'], $_GET['value'] );
-	 *
-	 * While changing to false will be fine for queries not using formatted/argnum placeholders,
-	 * any remaining cases are most likely going to result in SQL errors (good, in a way):
-	 *
-	 *     $wpdb->prepare( 'WHERE %1s = "%-10s"', 'my_field', 'my_value' );
-	 *     true  = WHERE my_field = "my_value  "
-	 *     false = WHERE 'my_field' = "'my_value  '"
-	 *
-	 * But there may be some queries that result in an SQL Injection vulnerability:
-	 *
-	 *     $wpdb->prepare( 'WHERE id = %1s', $_GET['id'] ); // ?id=id
-	 *
-	 * So there may need to be a `_doing_it_wrong()` phase, after we know everyone can use
-	 * identifier placeholders (%i), but before this feature is disabled or removed.
-	 *
-	 * @since 6.1.0
-	 * @var bool
-	 */
-	private $allow_unsafe_unquoted_parameters = true;
-
-	/**
 	 * Whether to use mysqli over mysql. Default false.
 	 *
 	 * @since 3.9.0
@@ -1398,37 +1363,6 @@
 	}
 
 	/**
-	 * Escapes an identifier for a MySQL database, e.g. table/field names.
-	 *
-	 * @since 6.1.0
-	 *
-	 * @param string $identifier Identifier to escape.
-	 * @return string Escaped identifier.
-	 */
-	public function escape_identifier( $identifier ) {
-		return '`' . $this->_escape_identifier_value( $identifier ) . '`';
-	}
-
-	/**
-	 * Escapes an identifier value without adding the surrounding quotes.
-	 *
-	 * - Permitted characters in quoted identifiers include the full Unicode
-	 *   Basic Multilingual Plane (BMP), except U+0000.
-	 * - To quote the identifier itself, you need to double the character, e.g. `a``b`.
-	 *
-	 * @since 6.1.0
-	 * @access private
-	 *
-	 * @link https://dev.mysql.com/doc/refman/8.0/en/identifiers.html
-	 *
-	 * @param string $identifier Identifier to escape.
-	 * @return string Escaped identifier.
-	 */
-	private function _escape_identifier_value( $identifier ) {
-		return str_replace( '`', '``', $identifier );
-	}
-
-	/**
 	 * Prepares a SQL query for safe execution.
 	 *
 	 * Uses sprintf()-like syntax. The following placeholders can be used in the query string:
@@ -1436,7 +1370,6 @@
 	 * - %d (integer)
 	 * - %f (float)
 	 * - %s (string)
-	 * - %i (identifier, e.g. table/field names)
 	 *
 	 * All placeholders MUST be left unquoted in the query string. A corresponding argument
 	 * MUST be passed for each placeholder.
@@ -1469,10 +1402,6 @@
 	 * @since 5.3.0 Formalized the existing and already documented `...$args` parameter
 	 *              by updating the function signature. The second parameter was changed
 	 *              from `$args` to `...$args`.
-	 * @since 6.1.0 Added `%i` for identifiers, e.g. table or field names.
-	 *              Check support via `wpdb::has_cap( 'identifier_placeholders' )`.
-	 *              This preserves compatibility with sprintf(), as the C version uses
-	 *              `%d` and `$i` as a signed integer, whereas PHP only supports `%d`.
 	 *
 	 * @link https://www.php.net/sprintf Description of syntax.
 	 *
@@ -1504,6 +1433,28 @@
 			);
 		}
 
+		// If args were passed as an array (as in vsprintf), move them up.
+		$passed_as_array = false;
+		if ( isset( $args[0] ) && is_array( $args[0] ) && 1 === count( $args ) ) {
+			$passed_as_array = true;
+			$args            = $args[0];
+		}
+
+		foreach ( $args as $arg ) {
+			if ( ! is_scalar( $arg ) && ! is_null( $arg ) ) {
+				wp_load_translations_early();
+				_doing_it_wrong(
+					'wpdb::prepare',
+					sprintf(
+						/* translators: %s: Value type. */
+						__( 'Unsupported value type (%s).' ),
+						gettype( $arg )
+					),
+					'4.8.2'
+				);
+			}
+		}
+
 		/*
 		 * Specify the formatting allowed in a placeholder. The following are allowed:
 		 *
@@ -1524,106 +1475,20 @@
 		 */
 		$query = str_replace( "'%s'", '%s', $query ); // Strip any existing single quotes.
 		$query = str_replace( '"%s"', '%s', $query ); // Strip any existing double quotes.
+		$query = preg_replace( '/(?<!%)%s/', "'%s'", $query ); // Quote the strings, avoiding escaped strings like %%s.
 
-		// Escape any unescaped percents (i.e. anything unrecognised).
-		$query = preg_replace( "/%(?:%|$|(?!($allowed_format)?[sdfFi]))/", '%%\\1', $query );
+		$query = preg_replace( "/(?<!%)(%($allowed_format)?f)/", '%\\2F', $query ); // Force floats to be locale-unaware.
 
-		// Extract placeholders from the query.
-		$split_query = preg_split( "/(^|[^%]|(?:%%)+)(%(?:$allowed_format)?[sdfFi])/", $query, -1, PREG_SPLIT_DELIM_CAPTURE );
+		$query = preg_replace( "/%(?:%|$|(?!($allowed_format)?[sdF]))/", '%%\\1', $query ); // Escape any unescaped percents.
 
-		$split_query_count = count( $split_query );
-		/*
-		 * Split always returns with 1 value before the first placeholder (even with $query = "%s"),
-		 * then 3 additional values per placeholder.
-		 */
-		$placeholder_count = ( ( $split_query_count - 1 ) / 3 );
+		// Count the number of valid placeholders in the query.
+		$placeholders = preg_match_all( "/(^|[^%]|(%%)+)%($allowed_format)?[sdF]/", $query, $matches );
 
-		// If args were passed as an array, as in vsprintf(), move them up.
-		$passed_as_array = ( isset( $args[0] ) && is_array( $args[0] ) && 1 === count( $args ) );
-		if ( $passed_as_array ) {
-			$args = $args[0];
-		}
-
-		$new_query       = '';
-		$key             = 2; // Keys 0 and 1 in $split_query contain values before the first placeholder.
-		$arg_id          = 0;
-		$arg_identifiers = array();
-		$arg_strings     = array();
-
-		while ( $key < $split_query_count ) {
-			$placeholder = $split_query[ $key ];
-
-			$format = substr( $placeholder, 1, -1 );
-			$type   = substr( $placeholder, -1 );
-
-			if ( 'f' === $type ) { // Force floats to be locale-unaware.
-				$type        = 'F';
-				$placeholder = '%' . $format . $type;
-			}
-
-			if ( 'i' === $type ) {
-				$placeholder = '`%' . $format . 's`';
-				// Using a simple strpos() due to previous checking (e.g. $allowed_format).
-				$argnum_pos = strpos( $format, '$' );
-
-				if ( false !== $argnum_pos ) {
-					// sprintf() argnum starts at 1, $arg_id from 0.
-					$arg_identifiers[] = ( intval( substr( $format, 0, $argnum_pos ) ) - 1 );
-				} else {
-					$arg_identifiers[] = $arg_id;
-				}
-			} elseif ( 'd' !== $type && 'F' !== $type ) {
-				/*
-				 * i.e. ( 's' === $type ), where 'd' and 'F' keeps $placeholder unchanged,
-				 * and we ensure string escaping is used as a safe default (e.g. even if 'x').
-				 */
-				$argnum_pos = strpos( $format, '$' );
-
-				if ( false !== $argnum_pos ) {
-					$arg_strings[] = ( intval( substr( $format, 0, $argnum_pos ) ) - 1 );
-				}
-
-				// Unquoted strings for backward compatibility (dangerous).
-				if ( true !== $this->allow_unsafe_unquoted_parameters || '' === $format ) {
-					$placeholder = "'%" . $format . "s'";
-				}
-			}
-
-			// Glue (-2), any leading characters (-1), then the new $placeholder.
-			$new_query .= $split_query[ $key - 2 ] . $split_query[ $key - 1 ] . $placeholder;
-
-			$key += 3;
-			$arg_id++;
-		}
-
-		// Replace $query; and add remaining $query characters, or index 0 if there were no placeholders.
-		$query = $new_query . $split_query[ $key - 2 ];
-
-		$dual_use = array_intersect( $arg_identifiers, $arg_strings );
-
-		if ( count( $dual_use ) ) {
-			wp_load_translations_early();
-			_doing_it_wrong(
-				'wpdb::prepare',
-				sprintf(
-					/* translators: %s: A comma-separated list of arguments found to be a problem. */
-					__( 'Arguments (%s) cannot be used for both String and Identifier escaping.' ),
-					implode( ', ', $dual_use )
-				),
-				'6.1.0'
-			);
-
-			return;
-		}
-
 		$args_count = count( $args );
 
-		if ( $args_count !== $placeholder_count ) {
-			if ( 1 === $placeholder_count && $passed_as_array ) {
-				/*
-				 * If the passed query only expected one argument,
-				 * but the wrong number of arguments was sent as an array, bail.
-				 */
+		if ( $args_count !== $placeholders ) {
+			if ( 1 === $placeholders && $passed_as_array ) {
+				// If the passed query only expected one argument, but the wrong number of arguments were sent as an array, bail.
 				wp_load_translations_early();
 				_doing_it_wrong(
 					'wpdb::prepare',
@@ -1644,7 +1509,7 @@
 					sprintf(
 						/* translators: 1: Number of placeholders, 2: Number of arguments passed. */
 						__( 'The query does not contain the correct number of placeholders (%1$d) for the number of arguments passed (%2$d).' ),
-						$placeholder_count,
+						$placeholders,
 						$args_count
 					),
 					'4.8.3'
@@ -1654,18 +1519,9 @@
 				 * If we don't have enough arguments to match the placeholders,
 				 * return an empty string to avoid a fatal error on PHP 8.
 				 */
-				if ( $args_count < $placeholder_count ) {
-					$max_numbered_placeholder = 0;
+				if ( $args_count < $placeholders ) {
+					$max_numbered_placeholder = ! empty( $matches[3] ) ? max( array_map( 'intval', $matches[3] ) ) : 0;
 
-					for ( $i = 2, $l = $split_query_count; $i < $l; $i += 3 ) {
-						// Assume a leading number is for a numbered placeholder, e.g. '%3$s'.
-						$argnum = intval( substr( $split_query[ $i ], 1 ) );
-
-						if ( $max_numbered_placeholder < $argnum ) {
-							$max_numbered_placeholder = $argnum;
-						}
-					}
-
 					if ( ! $max_numbered_placeholder || $args_count < $max_numbered_placeholder ) {
 						return '';
 					}
@@ -1673,36 +1529,9 @@
 			}
 		}
 
-		$args_escaped = array();
+		array_walk( $args, array( $this, 'escape_by_ref' ) );
+		$query = vsprintf( $query, $args );
 
-		foreach ( $args as $i => $value ) {
-			if ( in_array( $i, $arg_identifiers, true ) ) {
-				$args_escaped[] = $this->_escape_identifier_value( $value );
-			} elseif ( is_int( $value ) || is_float( $value ) ) {
-				$args_escaped[] = $value;
-			} else {
-				if ( ! is_scalar( $value ) && ! is_null( $value ) ) {
-					wp_load_translations_early();
-					_doing_it_wrong(
-						'wpdb::prepare',
-						sprintf(
-							/* translators: %s: Value type. */
-							__( 'Unsupported value type (%s).' ),
-							gettype( $value )
-						),
-						'4.8.2'
-					);
-
-					// Preserving old behavior, where values are escaped as strings.
-					$value = '';
-				}
-
-				$args_escaped[] = $this->_real_escape( $value );
-			}
-		}
-
-		$query = vsprintf( $query, $args_escaped );
-
 		return $this->add_placeholder_escape( $query );
 	}
 
@@ -3950,14 +3779,12 @@
 	 * @since 2.7.0
 	 * @since 4.1.0 Added support for the 'utf8mb4' feature.
 	 * @since 4.6.0 Added support for the 'utf8mb4_520' feature.
-	 * @since 6.1.0 Added support for the 'identifier_placeholders' feature.
 	 *
 	 * @see wpdb::db_version()
 	 *
 	 * @param string $db_cap The feature to check for. Accepts 'collation', 'group_concat',
-	 *                       'subqueries', 'set_charset', 'utf8mb4', 'utf8mb4_520',
-	 *                       or 'identifier_placeholders'.
-	 * @return bool True when the database feature is supported, false otherwise.
+	 *                       'subqueries', 'set_charset', 'utf8mb4', or 'utf8mb4_520'.
+	 * @return bool True when the database feature is supported, false otherwise
 	 */
 	public function has_cap( $db_cap ) {
 		$db_version     = $this->db_version();
@@ -4001,12 +3828,6 @@
 				}
 			case 'utf8mb4_520': // @since 4.6.0
 				return version_compare( $db_version, '5.6', '>=' );
-			case 'identifier_placeholders': // @since 6.1.0
-				/*
-				 * As of WordPress 6.1, wpdb::prepare() supports identifiers via '%i',
-				 * e.g. table/field names.
-				 */
-				return true;
 		}
 
 		return false;
Index: tests/phpunit/tests/db.php
===================================================================
--- tests/phpunit/tests/db.php	(revision 54725)
+++ tests/phpunit/tests/db.php	(working copy)
@@ -494,11 +494,9 @@
 		$this->assertTrue( $wpdb->has_cap( 'collation' ) );
 		$this->assertTrue( $wpdb->has_cap( 'group_concat' ) );
 		$this->assertTrue( $wpdb->has_cap( 'subqueries' ) );
-		$this->assertTrue( $wpdb->has_cap( 'identifier_placeholders' ) );
 		$this->assertTrue( $wpdb->has_cap( 'COLLATION' ) );
 		$this->assertTrue( $wpdb->has_cap( 'GROUP_CONCAT' ) );
 		$this->assertTrue( $wpdb->has_cap( 'SUBQUERIES' ) );
-		$this->assertTrue( $wpdb->has_cap( 'IDENTIFIER_PLACEHOLDERS' ) );
 		$this->assertSame(
 			version_compare( $wpdb->db_version(), '5.0.7', '>=' ),
 			$wpdb->has_cap( 'set_charset' )
@@ -1512,6 +1510,20 @@
 	}
 
 	/**
+	 * @ticket 56933
+	 *
+	 * @covers wpdb::prepare
+	 */
+	public function test_prepare_with_placeholders() {
+		global $wpdb;
+
+		$sql      = $wpdb->prepare( "'%%%s%%'", 'hello' );
+		$expected = '}hello{';
+
+		$this->assertStringContainsString( $expected, $sql );
+	}
+
+	/**
 	 * @dataProvider data_prepare_with_placeholders
 	 */
 	public function test_prepare_with_placeholders_and_individual_args( $sql, $values, $incorrect_usage, $expected ) {
@@ -1723,129 +1735,9 @@
 				false,
 				"'hello' 'foo##'",
 			),
-			array(
-				'SELECT * FROM %i WHERE %i = %d;',
-				array( 'my_table', 'my_field', 321 ),
-				false,
-				'SELECT * FROM `my_table` WHERE `my_field` = 321;',
-			),
-			array(
-				'WHERE %i = %d;',
-				array( 'evil_`_field', 321 ),
-				false,
-				'WHERE `evil_``_field` = 321;', // To quote the identifier itself, then you need to double the character, e.g. `a``b`.
-			),
-			array(
-				'WHERE %i = %d;',
-				array( 'evil_````````_field', 321 ),
-				false,
-				'WHERE `evil_````````````````_field` = 321;',
-			),
-			array(
-				'WHERE %i = %d;',
-				array( '``evil_field``', 321 ),
-				false,
-				'WHERE `````evil_field````` = 321;',
-			),
-			array(
-				'WHERE %i = %d;',
-				array( 'evil\'field', 321 ),
-				false,
-				'WHERE `evil\'field` = 321;',
-			),
-			array(
-				'WHERE %i = %d;',
-				array( 'evil_\``_field', 321 ),
-				false,
-				'WHERE `evil_\````_field` = 321;',
-			),
-			array(
-				'WHERE %i = %d;',
-				array( 'evil_%s_field', 321 ),
-				false,
-				"WHERE `evil_{$placeholder_escape}s_field` = 321;",
-			),
-			array(
-				'WHERE %i = %d;',
-				array( 'value`', 321 ),
-				false,
-				'WHERE `value``` = 321;',
-			),
-			array(
-				'WHERE `%i = %d;',
-				array( ' AND evil_value', 321 ),
-				false,
-				'WHERE `` AND evil_value` = 321;', // Won't run (SQL parse error: "Unclosed quote").
-			),
-			array(
-				'WHERE %i` = %d;',
-				array( 'evil_value -- ', 321 ),
-				false,
-				'WHERE `evil_value -- `` = 321;', // Won't run (SQL parse error: "Unclosed quote").
-			),
-			array(
-				'WHERE `%i`` = %d;',
-				array( ' AND true -- ', 321 ),
-				false,
-				'WHERE `` AND true -- ``` = 321;', // Won't run (Unknown column '').
-			),
-			array(
-				'WHERE ``%i` = %d;',
-				array( ' AND true -- ', 321 ),
-				false,
-				'WHERE ``` AND true -- `` = 321;', // Won't run (SQL parse error: "Unclosed quote").
-			),
-			array(
-				'WHERE %2$i = %1$d;',
-				array( '1', 'two' ),
-				false,
-				'WHERE `two` = 1;',
-			),
-			array(
-				'WHERE \'%i\' = 1 AND "%i" = 2 AND `%i` = 3 AND ``%i`` = 4 AND %15i = 5',
-				array( 'my_field1', 'my_field2', 'my_field3', 'my_field4', 'my_field5' ),
-				false,
-				'WHERE \'`my_field1`\' = 1 AND "`my_field2`" = 2 AND ``my_field3`` = 3 AND ```my_field4``` = 4 AND `      my_field5` = 5', // Does not remove any existing quotes, always adds it's own (safer).
-			),
-			array(
-				'WHERE id = %d AND %i LIKE %2$s LIMIT 1',
-				array( 123, 'field -- ', false ),
-				true, // Incorrect usage.
-				null, // Should be rejected, otherwise the `%1$s` could use Identifier escaping, e.g. 'WHERE `field -- ` LIKE field --  LIMIT 1' (thanks @vortfu).
-			),
-			array(
-				'WHERE %i LIKE %s LIMIT 1',
-				array( "field' -- ", "field' -- " ),
-				false,
-				"WHERE `field' -- ` LIKE 'field\' -- ' LIMIT 1", // In contrast to the above, Identifier vs String escaping is used.
-			),
 		);
 	}
 
-	public function test_allow_unsafe_unquoted_parameters() {
-		global $wpdb;
-
-		$sql    = 'WHERE (%i = %s) OR (%10i = %10s) OR (%5$i = %6$s)';
-		$values = array( 'field_a', 'string_a', 'field_b', 'string_b', 'field_c', 'string_c' );
-
-		$default = $wpdb->allow_unsafe_unquoted_parameters;
-
-		$wpdb->allow_unsafe_unquoted_parameters = true;
-
-		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
-		$part = $wpdb->prepare( $sql, $values );
-		$this->assertSame( 'WHERE (`field_a` = \'string_a\') OR (`   field_b` =   string_b) OR (`field_c` = string_c)', $part ); // Unsafe, unquoted parameters.
-
-		$wpdb->allow_unsafe_unquoted_parameters = false;
-
-		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
-		$part = $wpdb->prepare( $sql, $values );
-		$this->assertSame( 'WHERE (`field_a` = \'string_a\') OR (`   field_b` = \'  string_b\') OR (`field_c` = \'string_c\')', $part );
-
-		$wpdb->allow_unsafe_unquoted_parameters = $default;
-
-	}
-
 	/**
 	 * @dataProvider data_escape_and_prepare
 	 */
