From e288524ec03e94b2a9c10e7eb0878d23c0345001 Mon Sep 17 00:00:00 2001 From: MagicBot Date: Tue, 27 Oct 2026 06:42:31 -0400 Subject: [PATCH] Add a boot rotation parity check for the quaternion switch Under MB_PARITY_CHECK, after the mobs load, replays the old rotation code (building w/rotY, npc_rotation as cos of half the yaw, mob default 0, the old slot math and serializer) on the DB values and compares it with what the loaded objects now send. Yaws are compared modulo 2 pi within 1e-5. Dev-only; to be deleted once a run is clean. Co-Authored-By: Claude Opus 5.5 --- src/engine/server/world/WorldServer.java | 6 + src/engine/util/RotationParityCheck.java | 213 +++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 src/engine/util/RotationParityCheck.java 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); + } + +}