diff --git a/src/engine/server/world/WorldServer.java b/src/engine/server/world/WorldServer.java index 7cdb2c78..f8e90d7d 100644 --- a/src/engine/server/world/WorldServer.java +++ b/src/engine/server/world/WorldServer.java @@ -62,6 +62,7 @@ import engine.powers.RunePowerEntry; import engine.powers.RuneSkillAdjustEntry; import engine.server.MBServerStatics; import engine.util.MapLoader; +import engine.util.RotationParityCheck; import engine.util.ThreadUtils; import engine.workthreads.DisconnectTrashTask; import engine.workthreads.HourlyJobThread; @@ -544,6 +545,11 @@ public class WorldServer { Logger.info("time to load World Objects: " + (System.currentTimeMillis() - start) + " ms"); + if (ParityCheck.isEnabled()) { + Logger.info("Running rotation parity check"); + RotationParityCheck.run(); + } + // Configure realms for serialization // Doing this after the world is loaded diff --git a/src/engine/util/RotationParityCheck.java b/src/engine/util/RotationParityCheck.java new file mode 100644 index 00000000..6b584a9a --- /dev/null +++ b/src/engine/util/RotationParityCheck.java @@ -0,0 +1,213 @@ +// • ▌ ▄ ·. ▄▄▄· ▄▄ • ▪ ▄▄· ▄▄▄▄· ▄▄▄· ▐▄▄▄ ▄▄▄ . +// ·██ ▐███▪▐█ ▀█ ▐█ ▀ ▪██ ▐█ ▌▪▐█ ▀█▪▐█ ▀█ •█▌ ▐█▐▌· +// ▐█ ▌▐▌▐█·▄█▀▀█ ▄█ ▀█▄▐█·██ ▄▄▐█▀▀█▄▄█▀▀█ ▐█▐ ▐▌▐▀▀▀ +// ██ ██▌▐█▌▐█ ▪▐▌▐█▄▪▐█▐█▌▐███▌██▄▪▐█▐█ ▪▐▌██▐ █▌▐█▄▄▌ +// ▀▀ █▪▀▀▀ ▀ ▀ ·▀▀▀▀ ▀▀▀·▀▀▀ ·▀▀▀▀ ▀ ▀ ▀▀ █▪ ▀▀▀ +// Magicbane Emulator Project © 2013 - 2026 +// https://www.magicbane.com + +package engine.util; + +import engine.gameManager.DbManager; +import engine.math.FastMath; +import engine.math.Quaternion; +import engine.mbEnums.GameObjectType; +import engine.mbEnums.MobBehaviourType; +import engine.objects.*; +import org.pmw.tinylog.Logger; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; + +// Dev-only, World-Boot-Plan 0.4: replays the pre-quaternion rotation code on the DB values and compares its wire +// values with the loaded objects. Runs under MB_PARITY_CHECK; delete once a run is clean. + +public class RotationParityCheck { + + private static final float TOLERANCE = 1.0e-5f; + private static final int MAX_LOGGED = 20; + + private final String label; + private int comparedCount; + private int differenceCount; + private float maxDifference; + + private RotationParityCheck(String label) { + this.label = label; + } + + public static void run() { + + HashMap buildingRows; + HashMap npcRows; + + buildingRows = readBuildingRows(); + npcRows = readNpcRows(); + + checkBuildings(buildingRows); + checkNPCs(npcRows); + checkMobs(); + } + + private static void checkBuildings(HashMap buildingRows) { + + RotationParityCheck check = new RotationParityCheck("Building"); + Building building; + Quaternion rotation; + float[] row; + + for (AbstractGameObject object : DbManager.getList(GameObjectType.Building)) { + building = (Building) object; + row = buildingRows.get(building.getObjectUUID()); + + if (row == null) + continue; + + rotation = building.getRotation(); + + // The old serializer wrote (w, 0, rotY, 0). + check.compare(building.getObjectUUID(), "w", row[0], rotation.w); + check.compare(building.getObjectUUID(), "x", 0f, rotation.x); + check.compare(building.getObjectUUID(), "y", row[1], rotation.y); + check.compare(building.getObjectUUID(), "z", 0f, rotation.z); + } + + check.report(); + } + + private static void checkNPCs(HashMap npcRows) { + + RotationParityCheck check = new RotationParityCheck("NPC"); + NPC npc; + Float halfYawCosine; + + for (AbstractGameObject object : DbManager.getList(GameObjectType.NPC)) { + npc = (NPC) object; + halfYawCosine = npcRows.get(npc.getObjectUUID()); + + if (halfYawCosine == null) + continue; + + check.compare(npc.getObjectUUID(), "yaw", legacyYaw(npc, halfYawCosine, npc.building != null), npc.getClientYaw()); + } + + check.report(); + } + + private static void checkMobs() { + + RotationParityCheck check = new RotationParityCheck("Mob"); + Mob mob; + boolean slotted; + + for (AbstractGameObject object : DbManager.getList(GameObjectType.Mob)) { + mob = (Mob) object; + slotted = mob.building != null && (mob.contract != null || mob.behaviourType == MobBehaviourType.SiegeEngine); + + // Mob never loaded mob_rotation, so the old Vector3f default of 0 was its cos θ/2. + check.compare(mob.getObjectUUID(), "yaw", legacyYaw(mob, 0f, slotted), mob.getClientYaw()); + } + + check.report(); + } + + // The old slot code, rot.y holder and serializer, from the commit before the quaternion switch. + private static float legacyYaw(AbstractCharacter character, float halfYawCosine, boolean slotted) { + + Quaternion buildingRotation = null; + Quaternion slotRotation; + float yaw; + + if (character.building != null && character.building.getBounds() != null) + buildingRotation = character.building.getBounds().getQuaternion(); + + if (slotted && buildingRotation != null) { + slotRotation = new Quaternion().fromAngles(0, FastMath.acos(halfYawCosine) * 2, 0); + slotRotation = slotRotation.mult(buildingRotation); + halfYawCosine = slotRotation.y; + } + + yaw = (float) Math.acos(halfYawCosine) * 2; + + if (buildingRotation != null) + yaw += buildingRotation.angleY; + + return yaw; + } + + private static HashMap readBuildingRows() { + + HashMap rows = new HashMap<>(); + + try (Connection connection = DbManager.getConnection(); + PreparedStatement preparedStatement = connection.prepareStatement("SELECT `UID`, `w`, `rotY` FROM `obj_building`;")) { + + ResultSet rs = preparedStatement.executeQuery(); + + while (rs.next()) + rows.put(rs.getInt("UID"), new float[]{rs.getFloat("w"), rs.getFloat("rotY")}); + + } catch (SQLException e) { + Logger.error(e); + } + + return rows; + } + + private static HashMap readNpcRows() { + + HashMap rows = new HashMap<>(); + + try (Connection connection = DbManager.getConnection(); + PreparedStatement preparedStatement = connection.prepareStatement("SELECT `UID`, `npc_rotation` FROM `obj_npc`;")) { + + ResultSet rs = preparedStatement.executeQuery(); + + while (rs.next()) + rows.put(rs.getInt("UID"), rs.getFloat("npc_rotation")); + + } catch (SQLException e) { + Logger.error(e); + } + + return rows; + } + + // Angles are compared modulo 2π: the client only feeds them to MakeRotateY. + private void compare(int id, String field, float legacyValue, float newValue) { + + float difference; + + comparedCount++; + + if (Float.isNaN(legacyValue) && Float.isNaN(newValue)) + return; + + difference = Math.abs(newValue - legacyValue); + + if (field.equals("yaw")) + difference = Math.abs((float) Math.IEEEremainder(newValue - legacyValue, FastMath.TWO_PI)); + + if (Float.isNaN(difference)) + difference = Float.POSITIVE_INFINITY; + + maxDifference = Math.max(maxDifference, difference); + + if (difference <= TOLERANCE) + return; + + differenceCount++; + + if (differenceCount <= MAX_LOGGED) + Logger.warn("Rotation parity: " + label + "/" + id + " " + field + ": old " + legacyValue + ", new " + newValue); + } + + private void report() { + Logger.info("Rotation parity: " + label + ": " + comparedCount + " compared, " + differenceCount + + " differences over " + TOLERANCE + ", max difference " + maxDifference); + } + +}